Help us shape the future of Go! Take the 2026 Go Developer Survey

db

package
v0.56.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 11, 2026 License: MIT Imports: 22 Imported by: 0

Documentation

Overview

Package db defines a Driver interface that wraps a *sql.DB and exposes the typed Store interface used by the rest of the codebase.

Each concrete driver (sqlite, mysql, postgres) lives under db/drivers/ and adapts dialect-specific quirks (placeholder syntax, RETURNING support, JSON column type) into the same Store interface.

Migrations are embedded per-dialect via embed.FS and run by goose.

Index

Constants

View Source
const CatalogueFolderColumns = `storage_id, path_hash, path, depth, state, reconciled_at, visited_at, watched_at, reconcile_on_open, entries, held_back`

CatalogueFolderColumns is the select list both drivers use, in the order ScanCatalogueFolder reads them.

View Source
const CustomThemeColumns = `id, theme_key, name, tokens_light, tokens_dark, created_at, updated_at`

CustomThemeColumns is the select list both drivers use, in the order ScanCustomTheme reads them.

⚠ The order is a contract between this constant and the function below. They are next to each other so a column added to one is added to the other in the same edit; split across packages, a mismatch would scan the name into the light-token field and fail with a JSON error naming neither.

View Source
const LinkStateMax = 64

LinkStateMax bounds what is kept of a driver's reason. The three filex's own drivers give are a dozen characters; a storage plugin could say anything, and the reason travels to every listing that shows the row.

View Source
const PermRuleColumns = `` /* 188-byte string literal not displayed */

PermRuleColumns is the select list both drivers use for permission_rules, in the order ScanPermRule reads them. ⚠ The order is a contract with the function below — change them together.

View Source
const UnavailableReasonMax = 500

UnavailableReasonMax bounds what is kept of a storage's answer: it is shown to a person and written to a log, and a backend error can be a page long.

Variables

View Source
var ErrNestedTx = errors.New("db: this operation opens its own transaction and cannot run inside another")

ErrNestedTx refuses a store method that opens a transaction of its own while the context already carries one on the same database. On SQLite it would wait for the connection the outer transaction holds, for ever.

View Source
var ErrNoRows = sql.ErrNoRows

ErrNoRows is returned by Store methods when a single-row query has no result.

Functions

func AuditActionPrefixes

func AuditActionPrefixes(action string) ([]string, bool)

AuditActionPrefixes reads ListAuditFiltered's action filter: a value ending in "." ("user.") asks for every action of that resource, and a comma- separated list of them ("app_plugin.,app-plugins.") for every action of any of them — one resource can be written under two spellings (a handler's own row and the admin route's generic one). The answer is one LIKE pattern per prefix, with `\` as the escape character, so the underscore in "auth_provider." is a literal underscore. ok=false means an exact action (or no filter at all).

⚠ The Audit page's action filter was a free-text box matched EXACTLY against wire names ("user.create"): a person typing what the page shows them ("Kullanıcı") found nothing. The page now offers the resources by name and sends their prefixes.

func BeginOwnTx added in v0.45.0

func BeginOwnTx(ctx context.Context, pool *sql.DB) (*sql.Tx, error)

BeginOwnTx is BeginTx for a store method that commits on its own: refused (ErrNestedTx) inside a transaction RunInTx opened on the same database.

func CatalogueFolderDepth added in v0.44.2

func CatalogueFolderDepth(p string) int

CatalogueFolderDepth is how many segments deep a canonical folder path is (the root is 0).

func CatalogueFolderPath added in v0.44.2

func CatalogueFolderPath(dir string) string

CatalogueFolderPath is the canonical spelling of a catalogue folder: "/" for the storage root, "/a/b" below it — the spelling the walk stores in nodes.

func CatalogueSubtreeRange added in v0.44.2

func CatalogueSubtreeRange(dir string) (lo, hi string, ok bool)

CatalogueSubtreeRange is the byte range holding every canonical path strictly below dir: (lo, hi) exclusive. The migration makes `path` a byte-ordered column on every engine, and '0' is the byte after '/', so `path > lo AND path < hi` is exactly "starts with dir/" — "/a/b.txt" and "/a/bc" fall outside, and the index answers it. ok is false for the root, where every other row is below.

func CatalogueTime added in v0.44.2

func CatalogueTime(t *time.Time) any

CatalogueTime is how the SQLite/MySQL driver binds a catalogue timestamp: UTC, second precision, in CURRENT_TIMESTAMP's own spelling — so a bound value and a stored one compare as the instants they are (see ListStaleNodes for what an RFC 3339 binding did to that comparison).

func CollectPermOverrides added in v0.49.0

func CollectPermOverrides(rows *sql.Rows, err error) (map[int64]map[string]string, error)

CollectPermOverrides scans (user_id, overrides_json) rows into a map and closes them.

func CollectPermRules added in v0.49.0

func CollectPermRules(rows *sql.Rows, err error) ([]*model.PermissionRule, error)

CollectPermRules scans every row of a PermRuleColumns query and closes it.

func CollectStrings added in v0.49.0

func CollectStrings(rows *sql.Rows, err error) ([]string, error)

CollectStrings scans a one-string-column result set and closes it; an empty set is an empty (not nil) slice.

func CollectUserCustomRoles added in v0.49.0

func CollectUserCustomRoles(rows *sql.Rows, err error) (map[int64]int64, error)

CollectUserCustomRoles scans (user_id, role_id) rows into a map and closes them.

func CustomRoleOrNone added in v0.49.0

func CustomRoleOrNone(id int64, err error) (int64, error)

CustomRoleOrNone turns a single role_id lookup into 0 for "no row".

func CustomThemeJSON

func CustomThemeJSON(t *model.CustomTheme) (light string, dark string, err error)

CustomThemeJSON renders a theme's two token maps for storage.

func DecodePermConditions added in v0.49.0

func DecodePermConditions(s string) (model.PermRuleConditions, error)

DecodePermConditions parses a conditions column ("" and "{}" are none).

func DecodePermList added in v0.49.0

func DecodePermList(v sql.NullString) ([]string, error)

DecodePermList parses a permissions_json column into a non-nil list (possibly empty); NULL is empty.

func DecodePermOverrides added in v0.49.0

func DecodePermOverrides(s string) (map[string]string, error)

DecodePermOverrides parses an overrides / effects column. Empty text is an empty map (a MySQL JSON column never is, but a hand-edited row might be).

func DecodePermRule added in v0.49.0

func DecodePermRule(r *model.PermissionRule, targets, effects, settings string) error

DecodePermRule fills a rule's structured fields from its three columns.

func DecodePermTexts added in v0.49.0

func DecodePermTexts(s string) (map[string]string, error)

DecodePermTexts parses a names / descriptions column. Empty text and {} are nil, so a rule without translations reads back exactly as one written before the column existed.

func DollarPlaceholders added in v0.44.2

func DollarPlaceholders(q string) string

DollarPlaceholders numbers every `?` of a statement as PostgreSQL's $N. ⚠ For statements with no `?` inside a string literal — true of every statement in this file.

func EncodePermConditions added in v0.49.0

func EncodePermConditions(c model.PermRuleConditions) (string, error)

EncodePermConditions renders a rule's conditions column.

func EncodePermList added in v0.49.0

func EncodePermList(list []string) (any, error)

EncodePermList renders a role's permission list; nil is an empty one.

func EncodePermOverrides added in v0.49.0

func EncodePermOverrides(m map[string]string) (string, error)

EncodePermOverrides renders an overrides / effects map. nil encodes as {}.

func EncodePermRule added in v0.49.0

func EncodePermRule(r *model.PermissionRule) (targets, effects, settings string, err error)

EncodePermRule renders a rule's three JSON columns.

func EncodePermRuleAll added in v0.49.0

func EncodePermRuleAll(r *model.PermissionRule) (targets, effects, settings, conditions string, err error)

EncodePermRuleAll renders all four of a rule's JSON columns.

func EncodePermRuleTexts added in v0.49.0

func EncodePermRuleTexts(r *model.PermissionRule) (names, descriptions string, err error)

EncodePermRuleTexts renders a rule's two translation columns.

func EncodePermTexts added in v0.49.0

func EncodePermTexts(m map[string]string) (string, error)

EncodePermTexts renders a rule's names / descriptions column (migration 00071): language -> text. nil encodes as {}.

func EnsureNotEmpty

func EnsureNotEmpty(dsn string, hint string) error

EnsureNotEmpty surfaces the common "DSN missing" error consistently.

func IDList

func IDList(ids []int64, first int, ph func(n int) string) (string, []any)

IDList renders ids as a placeholder list with ph, for `IN (…)`.

func InTx added in v0.45.0

func InTx(ctx context.Context, pool *sql.DB) bool

InTx reports whether ctx carries a transaction on pool.

func Migrate

func Migrate(ctx context.Context, drv Driver, sqlDB *sql.DB) error

Migrate applies all UP migrations using goose with the driver-supplied embedded FS.

func MigrateDown

func MigrateDown(ctx context.Context, drv Driver, sqlDB *sql.DB) error

MigrateDown rolls back one step.

func MigrateStatus

func MigrateStatus(ctx context.Context, drv Driver, sqlDB *sql.DB) error

MigrateStatus prints status to stdout.

func Names

func Names() []string

Names returns the registered driver names sorted alphabetically.

func NotInDraftsSQL added in v0.48.0

func NotInDraftsSQL(col string) string

NotInDraftsSQL is the WHERE condition "the node at column col is not inside the drafts area", in both path spellings the drivers store (`/a/b` and `a/b`). The storage's counts (StorageStats) leave drafts out the way they leave the trash out: a draft is somebody's unfinished document, not a file of the storage yet (issue #71).

⚠ Safe to splice: syspath.Drafts is plain — no `%`, `_`, `\` or quote — so the pattern matches only itself (syspath's tests hold every internal name to that, for notify's LIKE patterns).

func OverridesOrEmpty added in v0.49.0

func OverridesOrEmpty(raw string, err error) (map[string]string, error)

OverridesOrEmpty turns the GetUserPermissionOverrides scan result into the Store contract: no row is an empty map, not an error.

func PlainTime added in v0.44.2

func PlainTime(t *time.Time) any

PlainTime binds a timestamp as the instant it is, in UTC (PostgreSQL).

func PlatformSubdomain added in v0.50.0

func PlatformSubdomain(p *model.Provider) string

PlatformSubdomain is a tenant's address under the tenant domain, "" when there is none (no tenant domain, the platform's own tenant, no realm).

func PrefixChars added in v0.47.0

func PrefixChars(p string) int

PrefixChars is the length SUBSTR needs for a path prefix: SQL counts CHARACTERS (SQLite, MySQL and PostgreSQL alike), Go's len counts bytes. Passing len() made every folder whose path is not plain ASCII match none of its own rows — "/Müşteri/" is 9 characters and 11 bytes — so the folder went to the trash and its contents stayed live, and a restore left them in the trash.

func ProviderRealmOnCreate added in v0.50.0

func ProviderRealmOnCreate(p *model.Provider) any

ProviderRealmOnCreate is the value a new tenant row's realm column gets (migration 00073), for every engine's CreateProvider: the realm the caller gave, else the slug — the same default the API offers and the migration used for the tenants that existed before it — lower-cased; NULL for a provider created as the supertenant, which has no realm.

It does not validate: the API checks a realm before it gets here (tenant.CheckRealm), and a caller that creates rows directly (the cloud signup, a test) passes a slug that is already realm-shaped.

⚠ There is deliberately no counterpart for UpdateProvider. A realm is set once and never rewritten (package tenant).

func RealmOfPlatformSubdomain added in v0.50.0

func RealmOfPlatformSubdomain(host string) string

RealmOfPlatformSubdomain returns the realm a host names when it is a platform subdomain (`acme.tenants.files.example` → `acme`), "" otherwise. Only one label sits in front of the tenant domain.

func Register

func Register(name string, f Factory)

Register adds a driver factory, called from drivers' init().

func ReplaceUserSSOGroups added in v0.49.0

func ReplaceUserSSOGroups(ctx context.Context, pool *sql.DB, del, ins string, userID int64, groups []string) error

ReplaceUserSSOGroups deletes a user's groups and inserts the given ones in one transaction — blanks and duplicates dropped. del takes (user_id), ins takes (user_id, group_name), each in the caller's dialect. A context that already carries a transaction on pool runs in that one (RunInTx).

func ResolveExtraHost added in v0.50.0

func ResolveExtraHost(ctx context.Context, s extraHostStore, host string) (*model.Provider, error)

ResolveExtraHost answers steps 2 and 3 above for a host no provider's `host` claims: the enabled tenant an active own domain or a platform subdomain names, or (nil, nil).

func RunInTx added in v0.45.0

func RunInTx(ctx context.Context, pool *sql.DB, fn func(ctx context.Context) error) error

RunInTx runs fn in one transaction on pool: every statement fn runs through a Store on pool with the context it is handed is part of it. It commits when fn returns nil and rolls back otherwise (fn's error is returned). A context already carrying a transaction on pool runs fn in that one.

⚠ Do not keep fn's context: once RunInTx returns, a statement run with it fails (sql.ErrTxDone).

func ScanCatalogueFolder added in v0.44.2

func ScanCatalogueFolder(r RowScanner) (*model.CatalogueFolder, error)

ScanCatalogueFolder reads one row in CatalogueFolderColumns order.

func ScanCustomTheme

func ScanCustomTheme(rows *sql.Rows) *model.CustomTheme

ScanCustomTheme turns the current row into a model, or nil when the row cannot be read.

⚠ Unparseable is nil, NOT an error. This runs on the public appearance path that the login page and every share page read; a row hand-edited into the database, or written by a future version with a shape this one cannot read, must cost the operator that one theme rather than the whole instance.

func ScanCustomThemes

func ScanCustomThemes(rows *sql.Rows) ([]*model.CustomTheme, error)

ScanCustomThemes drains a result set, dropping rows that cannot be read.

func ScanPermRule added in v0.49.0

func ScanPermRule(r RowScanner) (*model.PermissionRule, error)

ScanPermRule reads one permission_rules row selected with PermRuleColumns.

func SetTenantDomain added in v0.50.0

func SetTenantDomain(d string)

SetTenantDomain sets the installation's tenant domain (FILEX_TENANT_DOMAIN): `tenants.files.example` gives every tenant the address `<realm>.tenants.files.example`. "" switches platform subdomains off. Set once at boot (server.New); tests set and reset it.

⚠ A process-wide value and not a Store field: the store is wrapped and handed around (identitystore, quotastore, tenantstore) long before anyone could thread a setting through every wrapper, and every one of them must answer the same.

func SubtreePrefixVariants added in v0.47.0

func SubtreePrefixVariants(paths []string) []string

SubtreePrefixVariants normalizes candidate folder paths into the two on-disk conventions the nodes.path column historically mixes (with and without a leading slash), each with a trailing "/" so only strict descendants match. The storage root is never a subtree.

func SubtreeSuffix added in v0.47.0

func SubtreeSuffix(p string, prefixes []string) string

SubtreeSuffix strips the first of prefixes p starts with off p ("" when none matches).

func TagQueryWhere

func TagQueryWhere(q model.TagQuery, alias string, first int, ph func(n int) string) (string, []any)

TagQueryWhere renders a model.TagQuery as a SQL predicate over the `tags` table aliased `alias`, with the placeholder syntax the caller's engine uses (`?` for SQLite/MySQL, `$n` for Postgres — `ph(n)` is given the 1-based position counting from `first`).

ONE builder for both store implementations. The predicate is the visibility boundary between one person's tags and another's, and between one tenant's and another's; two hand-written copies of it are two chances for the engines to disagree about who sees a tag.

An empty query (no owner, no team) renders "1=0": a caller that asked for nothing gets nothing, never "no filter".

func TenantDomain added in v0.50.0

func TenantDomain() string

TenantDomain returns the installation's tenant domain, "" for none.

Types

type AppLink struct {
	Plugin string `json:"plugin"`
	Page   string `json:"page"`
	// Label names the kind of link ({lang: …}): "Signing request".
	Label map[string]string `json:"label"`
	// Revoke is what revoking it does ({lang: …}); empty when the page says
	// nothing, and the list then asks its ordinary question.
	Revoke map[string]string `json:"revoke,omitempty"`
	// View / Section: where in the app the row opens (`/app/<plugin>/<view>
	// ?section=`). Empty View when the app has no home page to open.
	View    string `json:"view,omitempty"`
	Section string `json:"section,omitempty"`
}

AppLink is what a list of links needs to show an app's link for what it is (wire.PagePurpose): the owner's decision of 2026-09-21 — a signing link in My shares is marked as a signing request, opens the request's page in the app, and its revoke says that it cancels the request.

type AppStoreSQL added in v0.52.0

type AppStoreSQL struct {
	Pool *sql.DB
	// Placeholders turns `?` into the engine's own (PostgreSQL:
	// DollarPlaceholders). Nil for SQLite and MySQL.
	Placeholders func(q string) string
	// Time binds a timestamp: CatalogueTime for SQLite and MySQL, PlainTime
	// for PostgreSQL. Nil = CatalogueTime.
	Time func(t *time.Time) any
}

AppStoreSQL implements the app_store_state half of Store (migration 00081, internal/appstore), written ONCE for every engine and embedded in each driver's Store - the arrangement of LoginThrottleSQL and PluginRequestSQL.

It only reads and writes opaque rows (a key and a JSON value); what a row means - a trusted store, a license, a used install link - is internal/appstore's.

func (*AppStoreSQL) DeleteAppStoreState added in v0.52.0

func (p *AppStoreSQL) DeleteAppStoreState(ctx context.Context, key string) (bool, error)

DeleteAppStoreState removes one row and reports whether there was one.

func (*AppStoreSQL) GetAppStoreState added in v0.52.0

func (p *AppStoreSQL) GetAppStoreState(ctx context.Context, key string) (string, bool, error)

GetAppStoreState returns one row's value; ok is false when there is none.

func (*AppStoreSQL) ListAppStoreState added in v0.52.0

func (p *AppStoreSQL) ListAppStoreState(ctx context.Context, prefix string) (map[string]string, error)

ListAppStoreState returns every row whose key starts with prefix, by key.

The prefix is compared in Go, not with LIKE: a key holds `_` and `%` (an origin, an app name) that LIKE would read as wildcards, and the table is a handful of rows.

func (*AppStoreSQL) PutAppStoreState added in v0.52.0

func (p *AppStoreSQL) PutAppStoreState(ctx context.Context, key, value string) error

PutAppStoreState writes one row, creating it on first sight.

⚠ UPDATE-then-INSERT and not an upsert, because the upsert spellings differ per engine and this file is written once (LoginThrottleSQL). The loser of two writers racing on a new key is answered by writing over the winner.

type AuditEntryWithUser

type AuditEntryWithUser struct {
	Entry     *model.AuditEntry `json:"entry"`
	UserEmail string            `json:"user_email,omitempty"`
	// TargetName is the thing the row is about, in words (a user's e-mail,
	// a storage's name, a file's path) — filled by the audit handler, not
	// by the query (handlers/audit_targets.go).
	TargetName string `json:"target_name,omitempty"`
	// UserName is who acted, as every screen names a person
	// (model.PersonLabel), filled by the audit handler — never by the store.
	UserName string `json:"user_name,omitempty"`
	// Label is the action in words and TargetLabel the thing it is about,
	// in the reader's language (handlers/audit_label.go) — filled by the
	// audit handler, never by the store.
	Label       string `json:"label,omitempty"`
	TargetLabel string `json:"target_label,omitempty"`
}

AuditEntryWithUser is an audit row joined with the user.email column for nicer admin UI rendering.

type CatalogueDialect added in v0.44.2

type CatalogueDialect struct {
	// Placeholders turns a statement written with `?` into the engine's
	// (PostgreSQL: $1, $2 …; DollarPlaceholders).
	Placeholders func(q string) string
	// Upsert rewrites SQLite's `ON CONFLICT (…) DO UPDATE SET` tail for the
	// engine (MySQL: ON DUPLICATE KEY UPDATE). PostgreSQL speaks SQLite's.
	Upsert func(q string) string
	// Time binds a timestamp (nil for NULL): CatalogueTime for SQLite and
	// MySQL, which compare stored timestamps as text; PlainTime for
	// PostgreSQL.
	Time func(t *time.Time) any
}

CatalogueDialect is what the engines disagree on for these statements. A nil function is the identity (SQLite's own spelling).

type CatalogueFolderSQL added in v0.44.2

type CatalogueFolderSQL struct {
	DB      *sql.DB
	Dialect CatalogueDialect
}

CatalogueFolderSQL implements the catalogue_folders half of db.Store.

func (*CatalogueFolderSQL) CountCatalogueFolders added in v0.44.2

func (c *CatalogueFolderSQL) CountCatalogueFolders(ctx context.Context, storageID int64) (model.CatalogueCounts, error)

func (*CatalogueFolderSQL) DeleteCatalogueFoldersUnder added in v0.44.2

func (c *CatalogueFolderSQL) DeleteCatalogueFoldersUnder(ctx context.Context, storageID int64, dir string) error

func (*CatalogueFolderSQL) DiscoverCatalogueFolders added in v0.44.2

func (c *CatalogueFolderSQL) DiscoverCatalogueFolders(ctx context.Context, storageID int64, folders []model.CatalogueFolder) error

func (*CatalogueFolderSQL) GetCatalogueFolder added in v0.44.2

func (c *CatalogueFolderSQL) GetCatalogueFolder(ctx context.Context, storageID int64, pathHash string) (*model.CatalogueFolder, error)

func (*CatalogueFolderSQL) HasUncataloguedUnder added in v0.44.2

func (c *CatalogueFolderSQL) HasUncataloguedUnder(ctx context.Context, storageID int64, dir string) (bool, error)

func (*CatalogueFolderSQL) ListCatalogueFolders added in v0.44.2

func (c *CatalogueFolderSQL) ListCatalogueFolders(ctx context.Context, storageID int64, f model.CatalogueFolderFilter) ([]*model.CatalogueFolder, error)

func (*CatalogueFolderSQL) RecordCatalogueFolder added in v0.44.2

func (c *CatalogueFolderSQL) RecordCatalogueFolder(ctx context.Context, f *model.CatalogueFolder) error

func (*CatalogueFolderSQL) ResetCatalogueWatches added in v0.44.2

func (c *CatalogueFolderSQL) ResetCatalogueWatches(ctx context.Context, storageID int64) (int64, error)

func (*CatalogueFolderSQL) SetCatalogueFolderWatch added in v0.44.2

func (c *CatalogueFolderSQL) SetCatalogueFolderWatch(ctx context.Context, storageID int64, pathHash string, watchedAt *time.Time, reconcileOnOpen bool) error

func (*CatalogueFolderSQL) TouchCatalogueFolderVisit added in v0.44.2

func (c *CatalogueFolderSQL) TouchCatalogueFolderVisit(ctx context.Context, storageID int64, pathHash string, at time.Time) error

type CoEditSQL added in v0.56.0

type CoEditSQL struct {
	Pool *sql.DB
	// Placeholders turns `?` into the engine's own (PostgreSQL:
	// DollarPlaceholders). Nil for SQLite and MySQL.
	Placeholders func(q string) string
}

CoEditSQL implements the editing-together half of Store (migration 00107, internal/coedit, task #189), written ONCE for every engine and embedded in each driver's Store - the arrangement of VaultLockSQL.

Every column is an integer, text or bytes: times are Unix milliseconds, so nothing here depends on how an engine stores or compares a timestamp. A session's state moves by compare-and-set on `rev` (UpdateCoEditSession): the relay reads the session, decides, and writes it back only if nobody else wrote it in between, in the same transaction as the entry, member or blob row the decision adds. The relay never reads a sealed payload; this file only stores and returns it.

func (*CoEditSQL) DeleteCoEditMember added in v0.56.0

func (p *CoEditSQL) DeleteCoEditMember(ctx context.Context, sid, client string) (bool, error)

DeleteCoEditMember removes a member, and its cursor row; ok is false when it was not there.

func (*CoEditSQL) DeleteCoEditSession added in v0.56.0

func (p *CoEditSQL) DeleteCoEditSession(ctx context.Context, sid string) error

DeleteCoEditSession removes a session and everything kept for it: its members, its log and its blobs' rows (the blob files are the caller's).

func (*CoEditSQL) GetCoEditBlob added in v0.56.0

func (p *CoEditSQL) GetCoEditBlob(ctx context.Context, sid, name string) (*model.CoEditBlob, error)

GetCoEditBlob returns a stored blob's row, or (nil, nil).

func (*CoEditSQL) GetCoEditMember added in v0.56.0

func (p *CoEditSQL) GetCoEditMember(ctx context.Context, sid, client string) (*model.CoEditMember, error)

GetCoEditMember returns one member, or (nil, nil).

func (*CoEditSQL) GetCoEditSession added in v0.56.0

func (p *CoEditSQL) GetCoEditSession(ctx context.Context, sid string) (*model.CoEditSession, error)

GetCoEditSession returns the session sid, or (nil, nil).

func (*CoEditSQL) GetCoEditSessionByDoc added in v0.56.0

func (p *CoEditSQL) GetCoEditSessionByDoc(ctx context.Context, docKey string) (*model.CoEditSession, error)

GetCoEditSessionByDoc returns the session of a file, or (nil, nil).

func (*CoEditSQL) InsertCoEditBlob added in v0.56.0

func (p *CoEditSQL) InsertCoEditBlob(ctx context.Context, b *model.CoEditBlob) (bool, error)

InsertCoEditBlob records a stored blob. ok is false when the name is taken: a blob is written once.

func (*CoEditSQL) InsertCoEditEntry added in v0.56.0

func (p *CoEditSQL) InsertCoEditEntry(ctx context.Context, e *model.CoEditEntry) error

InsertCoEditEntry writes one entry at its seq. The (sid, seq) key refuses a second entry at the same place.

func (*CoEditSQL) InsertCoEditMember added in v0.56.0

func (p *CoEditSQL) InsertCoEditMember(ctx context.Context, m *model.CoEditMember) error

InsertCoEditMember adds a member. The client id is the session's to hand out (next_client, under the session's compare-and-set), so it is new.

func (*CoEditSQL) InsertCoEditSession added in v0.56.0

func (p *CoEditSQL) InsertCoEditSession(ctx context.Context, s *model.CoEditSession) (bool, error)

InsertCoEditSession creates a session at rev 1. ok is false when the file already has one (or the id is taken): another writer was first, and the caller reads that one.

func (*CoEditSQL) ListCoEditCursors added in v0.56.0

func (p *CoEditSQL) ListCoEditCursors(ctx context.Context, sid string, sinceMs int64) ([]*model.CoEditCursor, error)

ListCoEditCursors returns a session's cursors written after sinceMs.

func (*CoEditSQL) ListCoEditEntries added in v0.56.0

func (p *CoEditSQL) ListCoEditEntries(ctx context.Context, sid string, after int64, limit int) ([]*model.CoEditEntry, error)

ListCoEditEntries returns up to limit entries after `after`, in order.

func (*CoEditSQL) ListCoEditMembers added in v0.56.0

func (p *CoEditSQL) ListCoEditMembers(ctx context.Context, sid string) ([]*model.CoEditMember, error)

ListCoEditMembers returns a session's members, the first to join first.

func (*CoEditSQL) ListIdleCoEditSessions added in v0.56.0

func (p *CoEditSQL) ListIdleCoEditSessions(ctx context.Context, limit int) ([]*model.CoEditSession, error)

ListIdleCoEditSessions returns up to limit sessions nobody is in, the least recently active first: what the sweep judges.

func (*CoEditSQL) ListStaleCoEditMembers added in v0.56.0

func (p *CoEditSQL) ListStaleCoEditMembers(ctx context.Context, beforeMs int64, limit int) ([]*model.CoEditMember, error)

ListStaleCoEditMembers returns up to limit members last seen before beforeMs, of every session: the ones whose page has gone.

func (*CoEditSQL) PutCoEditCursor added in v0.56.0

func (p *CoEditSQL) PutCoEditCursor(ctx context.Context, c *model.CoEditCursor) error

PutCoEditCursor writes a member's latest sealed cursor over its row.

⚠ UPDATE-then-INSERT, as PutOfficeSession: the upsert spellings differ per engine and this file is written once. Two writers racing on a new row: the loser's INSERT meets the unique key and it writes with a second UPDATE.

func (*CoEditSQL) SetCoEditMemberCounter added in v0.56.0

func (p *CoEditSQL) SetCoEditMemberCounter(ctx context.Context, sid, client string, ctr int64) error

SetCoEditMemberCounter records a writer's counter (under the session's compare-and-set, in its transaction).

func (*CoEditSQL) TouchCoEditMember added in v0.56.0

func (p *CoEditSQL) TouchCoEditMember(ctx context.Context, sid, client string, seenMs int64) (bool, error)

TouchCoEditMember records a sign of life of a member's page. ok is false when the member is not (or no longer) in the session.

func (*CoEditSQL) UpdateCoEditSession added in v0.56.0

func (p *CoEditSQL) UpdateCoEditSession(ctx context.Context, s *model.CoEditSession, rev int64) (bool, error)

UpdateCoEditSession writes s over its row - only while the row is still at rev (the revision the caller read) - and moves it to rev+1. ok is false when another writer came first.

type CoEditStore added in v0.56.0

type CoEditStore interface {
	// The relay's sessions, members, sealed log and blob rows. A session
	// moves by compare-and-set on rev. Written once for every engine
	// (CoEditSQL).
	// GetCoEditSession / GetCoEditSessionByDoc answer (nil, nil) for none.
	GetCoEditSession(ctx context.Context, sid string) (*model.CoEditSession, error)
	GetCoEditSessionByDoc(ctx context.Context, docKey string) (*model.CoEditSession, error)
	// InsertCoEditSession creates a session at rev 1; ok = false when the
	// file already has one.
	InsertCoEditSession(ctx context.Context, s *model.CoEditSession) (bool, error)
	// UpdateCoEditSession writes s while the row is still at rev; ok = false
	// when another writer came first.
	UpdateCoEditSession(ctx context.Context, s *model.CoEditSession, rev int64) (bool, error)
	// DeleteCoEditSession removes a session, its members, log and blob rows.
	DeleteCoEditSession(ctx context.Context, sid string) error
	// ListIdleCoEditSessions: sessions nobody is in, least recently active first.
	ListIdleCoEditSessions(ctx context.Context, limit int) ([]*model.CoEditSession, error)
	ListCoEditMembers(ctx context.Context, sid string) ([]*model.CoEditMember, error)
	GetCoEditMember(ctx context.Context, sid, client string) (*model.CoEditMember, error)
	InsertCoEditMember(ctx context.Context, m *model.CoEditMember) error
	SetCoEditMemberCounter(ctx context.Context, sid, client string, ctr int64) error
	// TouchCoEditMember: ok = false when the member is not in the session.
	TouchCoEditMember(ctx context.Context, sid, client string, seenMs int64) (bool, error)
	DeleteCoEditMember(ctx context.Context, sid, client string) (bool, error)
	// ListStaleCoEditMembers: members last seen before beforeMs, any session.
	ListStaleCoEditMembers(ctx context.Context, beforeMs int64, limit int) ([]*model.CoEditMember, error)
	InsertCoEditEntry(ctx context.Context, e *model.CoEditEntry) error
	// ListCoEditEntries: up to limit entries after `after`, in order.
	ListCoEditEntries(ctx context.Context, sid string, after int64, limit int) ([]*model.CoEditEntry, error)
	// InsertCoEditBlob: ok = false when the name is taken.
	InsertCoEditBlob(ctx context.Context, b *model.CoEditBlob) (bool, error)
	GetCoEditBlob(ctx context.Context, sid, name string) (*model.CoEditBlob, error)
	// PutCoEditCursor writes a member's latest sealed cursor over its row
	// (migration 00109); ListCoEditCursors: a session's rows written after
	// sinceMs (Unix ms).
	PutCoEditCursor(ctx context.Context, c *model.CoEditCursor) error
	ListCoEditCursors(ctx context.Context, sid string, sinceMs int64) ([]*model.CoEditCursor, error)
}

CoEditStore is the editing-together half of Store (migration 00107, internal/coedit, #189): the relay asks for exactly this, beside WithTx, so the method set is written once.

type DigestSQL added in v0.53.0

type DigestSQL struct {
	Pool *sql.DB
	// Placeholders turns `?` into the engine's own (PostgreSQL:
	// DollarPlaceholders). Nil for SQLite and MySQL.
	Placeholders func(q string) string
	// Time binds a timestamp: CatalogueTime for SQLite and MySQL (they compare
	// stored timestamps as text), PlainTime for PostgreSQL. Nil = CatalogueTime.
	Time func(t *time.Time) any
}

DigestSQL implements the notification digest's half of Store (migration 00087, internal/notify digest.go), written ONCE for every engine and embedded in each driver's Store — the arrangement of DraftSQL, for the same reason (the duplication gate in web/tests/quality).

It only reads and writes rows: which notifications are held, when a window ends and what a digest says are internal/notify's.

⚠ No upsert: the three engines spell it three ways. A digest point is made by an INSERT that may lose a race (the row is then read back), a policy is replaced by a DELETE and an INSERT in one transaction.

func (*DigestSQL) AdvanceDigest added in v0.53.0

func (d *DigestSQL) AdvanceDigest(ctx context.Context, userID, from, to int64, now time.Time) (bool, error)

AdvanceDigest is a compare-and-set on through_id. ⚠ updated_at is written too: MySQL counts a row whose values did not change as not affected, and a set that does nothing else must still answer "it was mine".

func (*DigestSQL) ClearDigestDue added in v0.53.0

func (d *DigestSQL) ClearDigestDue(ctx context.Context, userID int64, now time.Time) error

func (*DigestSQL) DigestState added in v0.53.0

func (d *DigestSQL) DigestState(ctx context.Context, userID int64) (int64, *time.Time, bool, error)

func (*DigestSQL) DueDigests added in v0.53.0

func (d *DigestSQL) DueDigests(ctx context.Context, now time.Time, limit int) ([]int64, error)

func (*DigestSQL) EnsureDigestState added in v0.53.0

func (d *DigestSQL) EnsureDigestState(ctx context.Context, userID int64) (int64, error)

func (*DigestSQL) GetDigestPolicy added in v0.53.0

func (d *DigestSQL) GetDigestPolicy(ctx context.Context, scope int64) (*model.DigestPolicy, error)

func (*DigestSQL) MarkOwnNotificationsRead added in v0.53.0

func (d *DigestSQL) MarkOwnNotificationsRead(ctx context.Context, userID int64, ids []int64) error

func (*DigestSQL) NewestNotificationID added in v0.53.0

func (d *DigestSQL) NewestNotificationID(ctx context.Context) (int64, error)

func (*DigestSQL) OldestQuietOwn added in v0.53.0

func (d *DigestSQL) OldestQuietOwn(ctx context.Context, userID, after int64, events []string) (*time.Time, error)

func (*DigestSQL) SaveDigestPolicy added in v0.53.0

func (d *DigestSQL) SaveDigestPolicy(ctx context.Context, p *model.DigestPolicy) error

func (*DigestSQL) SetDigestDue added in v0.53.0

func (d *DigestSQL) SetDigestDue(ctx context.Context, userID int64, due time.Time, exact bool) error

type DraftSQL added in v0.48.0

type DraftSQL struct {
	Pool *sql.DB
	// Placeholders turns `?` into the engine's own (PostgreSQL:
	// DollarPlaceholders). Nil for SQLite and MySQL.
	Placeholders func(q string) string
}

DraftSQL implements the drafts half of Store (migration 00064, issue #71), written ONCE for every engine. Each driver embeds a *DraftSQL in its Store, so the methods are promoted and there is no per-driver copy to drift — the arrangement of StorageOrderSQL and NodeDeletedBySQL, and for the same reason (the duplication gate in web/tests/quality).

See db/migrations/sqlite/00064_drafts.sql for the model and internal/drafts for the rules; this only reads and writes the rows.

func (*DraftSQL) CountLiveDrafts added in v0.48.0

func (d *DraftSQL) CountLiveDrafts(ctx context.Context, userID int64) (int, error)

CountLiveDrafts counts a person's drafts whose file is live.

func (*DraftSQL) CreateDraft added in v0.48.0

func (d *DraftSQL) CreateDraft(ctx context.Context, in *model.Draft) (*model.Draft, error)

CreateDraft inserts a draft row and returns it as the store reads it back.

func (*DraftSQL) DeleteDraft added in v0.48.0

func (d *DraftSQL) DeleteDraft(ctx context.Context, id int64) error

DeleteDraft removes a draft row. Its file is the caller's business.

func (*DraftSQL) GetDraftByKey added in v0.48.0

func (d *DraftSQL) GetDraftByKey(ctx context.Context, key string) (*model.Draft, error)

GetDraftByKey returns the draft with that key, live or not.

func (*DraftSQL) GetDraftByNode added in v0.48.0

func (d *DraftSQL) GetDraftByNode(ctx context.Context, nodeID int64) (*model.Draft, error)

GetDraftByNode returns the draft whose file is that node, live or not.

func (*DraftSQL) ListLiveDrafts added in v0.48.0

func (d *DraftSQL) ListLiveDrafts(ctx context.Context, userID int64) ([]*model.Draft, error)

ListLiveDrafts returns a person's drafts whose file is live, the most recently made first (the client sorts by whatever column it likes).

type Driver

type Driver interface {
	Name() string
	Open(ctx context.Context, dsn string) (*sql.DB, error)
	MigrationsFS() embed.FS // embed.FS rooted at the dialect's migration directory
	Dialect() string        // goose-compatible: "sqlite3", "postgres", "mysql"
	NewStore(db *sql.DB) Store
}

Driver describes a database engine integration.

func Get

func Get(name string) (Driver, error)

Get returns a freshly-constructed driver instance, error if unknown.

func MustGet

func MustGet(name string) Driver

MustGet panics on unknown driver — for tests / static configs.

type DuplicateNode

type DuplicateNode struct {
	ID        int64
	StorageID int64
	Path      string
	Name      string
	Size      int64
	Etag      string
}

DuplicateNode is one row of the duplicate-file report query — a slim projection of nodes (no timestamps/sync state) since the report only needs identity + grouping fields.

type E2ERequestSQL added in v0.51.0

type E2ERequestSQL struct {
	Pool *sql.DB
	// Placeholders turns `?` into the engine's own (PostgreSQL:
	// DollarPlaceholders). Nil for SQLite and MySQL.
	Placeholders func(q string) string
	// Time binds a timestamp: CatalogueTime for SQLite and MySQL (they compare
	// stored timestamps as text), PlainTime for PostgreSQL. Nil = CatalogueTime.
	Time func(t *time.Time) any
}

E2ERequestSQL implements the e2e_requests half of Store (migration 00080, internal/e2epolicy): a person's request to encrypt in one folder where the tenant's policy wants an administrator's approval first. Written ONCE for every engine and embedded in each driver's Store — the arrangement of PluginRequestSQL, whose helpers it uses (int64Arg, nullInt64Ptr, and catalogue_scan.go's nullTimePtr) instead of carrying copies: the duplication gate in web/tests/quality reads Go too.

It only reads and writes rows. What a request means, and which state may follow which, is internal/e2epolicy's and the requests service's.

func (*E2ERequestSQL) CreateE2ERequest added in v0.51.0

func (p *E2ERequestSQL) CreateE2ERequest(ctx context.Context, in *model.E2ERequest) (*model.E2ERequest, error)

CreateE2ERequest inserts a request and returns it as the store reads it back. A request without a status is pending; a new request is undecided, so the decision fields are not written (UpdateE2ERequest writes them).

func (*E2ERequestSQL) FindApprovedE2ERequest added in v0.51.0

func (p *E2ERequestSQL) FindApprovedE2ERequest(ctx context.Context, userID, storageID int64, path, kind string, now time.Time) (*model.E2ERequest, error)

FindApprovedE2ERequest returns an approval the person may still use for an encryption of kind at path on that storage: approved, and not yet expired at now. The one that expires first wins when there are several. sql.ErrNoRows when there is none. Finding it does not use it up — UpdateE2ERequest with onlyIfStatus approved does, once.

⚠ path is compared exactly, byte for byte (MySQL's column is binary for that reason): callers spell it as internal/e2epolicy.ApprovalPath does.

func (*E2ERequestSQL) GetE2ERequest added in v0.51.0

func (p *E2ERequestSQL) GetE2ERequest(ctx context.Context, id int64) (*model.E2ERequest, error)

GetE2ERequest returns one request, sql.ErrNoRows when there is none.

func (*E2ERequestSQL) ListE2ERequests added in v0.51.0

func (p *E2ERequestSQL) ListE2ERequests(ctx context.Context, f model.E2ERequestFilter) ([]*model.E2ERequest, error)

ListE2ERequests returns the requests f matches, the newest first, at most f.Limit of them (<= 0 or over 500: 500). With f.ExpiresBefore it returns what is due at that instant, the longest overdue first (a tie by id): the order a sweep reads in, a batch at a time.

func (*E2ERequestSQL) UpdateE2ERequest added in v0.51.0

func (p *E2ERequestSQL) UpdateE2ERequest(ctx context.Context, r *model.E2ERequest, onlyIfStatus string) (bool, error)

UpdateE2ERequest writes a request's state: the status, who decided and when, the note, the expiry (an approval's own seven days start at the decision) and when it was used. With onlyIfStatus the write happens only while the stored row is still in that state, and ok reports whether it did: two administrators deciding one request, or two writes spending one approval, cannot both win.

⚠ MySQL counts CHANGED rows, so a write that changes nothing reports false. Every transition changes the status, so a real one never does.

type ExternalService

type ExternalService struct {
	Name        string
	Enabled     bool
	URL         string
	SecretEnc   string
	OptionsJSON string
	LastCheck   *time.Time
	LastState   string
}

ExternalService is the DB row representation. Lives in the db package so model can stay pure-domain.

type Factory

type Factory func() Driver

Factory builds a fresh Driver instance.

type FileAssocSQL added in v0.50.0

type FileAssocSQL struct {
	Pool *sql.DB
	// Placeholders turns `?` into the engine's own (PostgreSQL:
	// DollarPlaceholders). Nil for SQLite and MySQL.
	Placeholders func(q string) string
	// Time binds a timestamp: CatalogueTime for SQLite and MySQL, PlainTime
	// for PostgreSQL. Nil = CatalogueTime.
	Time func(t *time.Time) any
}

FileAssocSQL implements the file-association half of Store (migration 00077, internal/assoc): the administrator's rule per kind and capability, and the per-app thumbnail limits. Written ONCE for every engine and embedded in each driver's Store - the arrangement of PluginRequestSQL, for the same reason (the duplication gate in web/tests/quality).

⚠ An upsert is a delete and an insert in one transaction rather than each engine's own ON CONFLICT / ON DUPLICATE KEY: the three spellings differ, and a table this small does not need them.

func (*FileAssocSQL) DeleteAppThumbLimits added in v0.50.0

func (f *FileAssocSQL) DeleteAppThumbLimits(ctx context.Context, pluginID int64) error

DeleteAppThumbLimits removes one app's limits (the app is removed).

func (*FileAssocSQL) DeleteFileAssociation added in v0.50.0

func (f *FileAssocSQL) DeleteFileAssociation(ctx context.Context, capability, ext string) (bool, error)

DeleteFileAssociation removes the rule for (capability, ext): the kind keeps the default order again. ok = there was one.

func (*FileAssocSQL) GetAppThumbLimits added in v0.50.0

func (f *FileAssocSQL) GetAppThumbLimits(ctx context.Context, pluginID int64) (*model.AppThumbLimits, error)

GetAppThumbLimits answers (nil, nil) when the app has none stored.

func (*FileAssocSQL) ListFileAssociations added in v0.50.0

func (f *FileAssocSQL) ListFileAssociations(ctx context.Context) ([]*model.FileAssociation, error)

ListFileAssociations returns every rule, by capability then kind.

func (*FileAssocSQL) ListUserPrefs added in v0.50.0

func (f *FileAssocSQL) ListUserPrefs(ctx context.Context, userID int64) ([]UserPrefsDoc, error)

ListUserPrefs returns every preference document one person stored (00047), newest first, each with when it was last written.

It rides on FileAssocSQL, the shared statement set every driver embeds, because it serves the same 0.50 feature: the account-wide "open with" choices (handlers/openwith.go) are adopted once from the most recently written surface document, and only this listing knows which one that is. One statement for every engine: it only reads, and `?` is rebound for PostgreSQL.

func (*FileAssocSQL) PutAppThumbLimits added in v0.50.0

func (f *FileAssocSQL) PutAppThumbLimits(ctx context.Context, l *model.AppThumbLimits) error

PutAppThumbLimits creates or replaces one app's limits.

func (*FileAssocSQL) PutFileAssociation added in v0.50.0

func (f *FileAssocSQL) PutFileAssociation(ctx context.Context, a *model.FileAssociation) error

PutFileAssociation creates or replaces the rule for (Capability, Ext).

func (*FileAssocSQL) ThumbnailGenerators added in v0.50.0

func (f *FileAssocSQL) ThumbnailGenerators(ctx context.Context, storageIDs []int64) (map[string]int64, error)

ThumbnailGenerators counts the ready thumbnails of live files by who drew them, within storageIDs (nil: every storage, empty: none).

type GroupSQL added in v0.50.0

type GroupSQL struct {
	// contains filtered or unexported fields
}

GroupSQL is the group store for one connection.

func NewGroupSQL added in v0.50.0

func NewGroupSQL(conn *sql.DB, dollar bool) *GroupSQL

NewGroupSQL returns the group store for conn. dollar is true for PostgreSQL: `$n` placeholders, BOOLEAN columns and INSERT … RETURNING.

func (*GroupSQL) AddGroupMember added in v0.50.0

func (g *GroupSQL) AddGroupMember(ctx context.Context, groupID, userID int64) error

AddGroupMember puts a person in a group by hand. A membership a link made becomes a manual one — the person now stays whatever the directory says.

func (*GroupSQL) CreateGroup added in v0.50.0

func (g *GroupSQL) CreateGroup(ctx context.Context, gr *model.Group) (*model.Group, error)

CreateGroup stores a new group and returns it as stored.

func (*GroupSQL) CreateGroupFileGrant added in v0.50.0

func (g *GroupSQL) CreateGroupFileGrant(ctx context.Context, gr *model.FileGrant) (*model.FileGrant, error)

CreateGroupFileGrant upserts a group's grant on the (storage_id, path_prefix, group_id) key, like CreateFileGrant does a person's.

func (*GroupSQL) DeleteGroup added in v0.50.0

func (g *GroupSQL) DeleteGroup(ctx context.Context, id int64) error

DeleteGroup deletes a group; its memberships and grants go with it (ON DELETE CASCADE — SQLite runs with foreign_keys on).

func (*GroupSQL) DeleteGroupFileGrant added in v0.50.0

func (g *GroupSQL) DeleteGroupFileGrant(ctx context.Context, id int64) error

DeleteGroupFileGrant revokes a group grant.

func (*GroupSQL) DeleteUserGroupLevel added in v0.50.0

func (g *GroupSQL) DeleteUserGroupLevel(ctx context.Context, userID int64) error

DeleteUserGroupLevel forgets it — the level is the account's own again.

func (*GroupSQL) DropForeignMemberships added in v0.50.0

func (g *GroupSQL) DropForeignMemberships(ctx context.Context, userID, providerID int64) error

DropForeignMemberships takes a person out of every group of a tenant other than providerID — what moving an account to another tenant does. The drivers' SetUserProvider calls it, so every path that re-homes an account does. Install-wide groups (no tenant) keep them.

func (*GroupSQL) GetGroup added in v0.50.0

func (g *GroupSQL) GetGroup(ctx context.Context, id int64) (*model.Group, error)

GetGroup returns one group, or sql.ErrNoRows.

func (*GroupSQL) GetGroupFileGrant added in v0.50.0

func (g *GroupSQL) GetGroupFileGrant(ctx context.Context, id int64) (*model.FileGrant, error)

GetGroupFileGrant returns one group grant, or sql.ErrNoRows.

func (*GroupSQL) GetUserGroupLevel added in v0.50.0

func (g *GroupSQL) GetUserGroupLevel(ctx context.Context, userID int64) (string, bool, error)

GetUserGroupLevel returns the built-in level an account had before a group's role moved it, and whether one is recorded.

func (*GroupSQL) ListAllGroupFileGrants added in v0.50.0

func (g *GroupSQL) ListAllGroupFileGrants(ctx context.Context) ([]*model.FileGrant, error)

ListAllGroupFileGrants returns every group grant.

func (*GroupSQL) ListAllGroupMembers added in v0.50.0

func (g *GroupSQL) ListAllGroupMembers(ctx context.Context) ([]*model.GroupMember, error)

ListAllGroupMembers returns every membership, in (group, user) order — what the permission snapshot reads once for the whole install.

func (*GroupSQL) ListGroupFileGrantsByGroup added in v0.50.0

func (g *GroupSQL) ListGroupFileGrantsByGroup(ctx context.Context, groupID int64) ([]*model.FileGrant, error)

ListGroupFileGrantsByGroup returns one group's grants, on every storage.

func (*GroupSQL) ListGroupFileGrantsByStorage added in v0.50.0

func (g *GroupSQL) ListGroupFileGrantsByStorage(ctx context.Context, storageID int64) ([]*model.FileGrant, error)

ListGroupFileGrantsByStorage returns every group grant on one storage.

func (*GroupSQL) ListGroupFileGrantsByStorageUser added in v0.50.0

func (g *GroupSQL) ListGroupFileGrantsByStorageUser(ctx context.Context, storageID, userID int64) ([]*model.FileGrant, error)

ListGroupFileGrantsByStorageUser returns the grants on one storage of every group the person is in — what they reach through their groups.

func (*GroupSQL) ListGroupMembers added in v0.50.0

func (g *GroupSQL) ListGroupMembers(ctx context.Context, groupID int64) ([]*model.GroupMember, error)

ListGroupMembers returns one group's members, in user id order.

func (*GroupSQL) ListGroups added in v0.50.0

func (g *GroupSQL) ListGroups(ctx context.Context) ([]*model.Group, error)

ListGroups returns every group, in id order.

func (*GroupSQL) ListUserGroupMemberships added in v0.50.0

func (g *GroupSQL) ListUserGroupMemberships(ctx context.Context, userID int64) ([]*model.GroupMember, error)

ListUserGroupMemberships returns the groups one person is in, in group id order.

func (*GroupSQL) ListUserLDAPGroups added in v0.52.0

func (g *GroupSQL) ListUserLDAPGroups(ctx context.Context, userID int64) ([]string, error)

ListUserLDAPGroups returns the groups of the account's latest LDAP sign-in (user_ldap_groups, migration 00084), in group.LDAPValue's form.

func (*GroupSQL) ReassignGroupRole added in v0.50.0

func (g *GroupSQL) ReassignGroupRole(ctx context.Context, from, to int64) error

ReassignGroupRole gives every group holding role `from` the role `to` instead (0: no role) — what deleting a role does to its groups.

func (*GroupSQL) RemoveGroupMember added in v0.50.0

func (g *GroupSQL) RemoveGroupMember(ctx context.Context, groupID, userID int64) error

RemoveGroupMember takes a person out of a group, whatever put them there.

func (*GroupSQL) SetGroupDirectory added in v0.52.0

func (g *GroupSQL) SetGroupDirectory(ctx context.Context, id int64, directoryID, name, state string) error

SetGroupDirectory records where a group comes from in a directory (directory sync — migration 00084): its permanent id there, the name it had there, and "" or model.GroupDirectoryRemoved. All "" makes it an ordinary filex group.

func (*GroupSQL) SetUserGroupLevel added in v0.50.0

func (g *GroupSQL) SetUserGroupLevel(ctx context.Context, userID int64, level string) error

SetUserGroupLevel records the level an account had before a group's role moved it. The first record stands: a second move is not "before".

func (*GroupSQL) SetUserLDAPGroups added in v0.52.0

func (g *GroupSQL) SetUserLDAPGroups(ctx context.Context, userID int64, groups []string) error

SetUserLDAPGroups replaces them with the groups a sign-in just reported.

func (*GroupSQL) SetUserLinkedGroups added in v0.50.0

func (g *GroupSQL) SetUserLinkedGroups(ctx context.Context, userID int64, source string, groupIDs []int64) (added, removed []int64, err error)

SetUserLinkedGroups makes groupIDs the groups a person is in through one kind of link (source): rows of that source not in groupIDs are removed, missing ones added. A membership of any other source — "manual" above all — is never touched, and is not duplicated. It reports the group ids that were added and removed.

func (*GroupSQL) UpdateGroup added in v0.50.0

func (g *GroupSQL) UpdateGroup(ctx context.Context, gr *model.Group) error

UpdateGroup rewrites a group's name, description, role, priority and links — never its tenant, which is fixed at creation. sql.ErrNoRows for an unknown id; a name another group of the tenant has is the engine's unique error.

func (*GroupSQL) UpdateGroupFileGrantLevel added in v0.50.0

func (g *GroupSQL) UpdateGroupFileGrantLevel(ctx context.Context, id int64, level string) error

UpdateGroupFileGrantLevel changes a group grant's level.

type LoginThrottleSQL added in v0.50.0

type LoginThrottleSQL struct {
	Pool *sql.DB
	// Placeholders turns `?` into the engine's own (PostgreSQL:
	// DollarPlaceholders). Nil for SQLite and MySQL.
	Placeholders func(q string) string
	// Time binds a timestamp: CatalogueTime for SQLite and MySQL (they compare
	// stored timestamps as text), PlainTime for PostgreSQL. Nil = CatalogueTime.
	Time func(t *time.Time) any
}

LoginThrottleSQL implements the login_throttle half of Store (migration 00072, internal/loginguard), written ONCE for every engine and embedded in each driver's Store — the arrangement of PluginRequestSQL and DraftSQL.

It only reads and writes counter rows; what a count means (the limits, the windows, the lock arithmetic) is internal/loginguard's.

func (*LoginThrottleSQL) DeleteLoginThrottle added in v0.50.0

func (p *LoginThrottleSQL) DeleteLoginThrottle(ctx context.Context, scope, subject string) (bool, error)

DeleteLoginThrottle removes one counter and reports whether there was one.

func (*LoginThrottleSQL) GetLoginThrottle added in v0.50.0

func (p *LoginThrottleSQL) GetLoginThrottle(ctx context.Context, scope, subject string) (*model.LoginThrottle, error)

GetLoginThrottle returns one counter, or (nil, nil) when nothing has been counted for that subject.

func (*LoginThrottleSQL) ListLoginThrottles added in v0.50.0

func (p *LoginThrottleSQL) ListLoginThrottles(ctx context.Context, scope string, lockedAt *time.Time, limit int) ([]*model.LoginThrottle, error)

ListLoginThrottles returns counters, the most recently active first, at most limit of them (<= 0: 200; never more than 1000). scope "" = every scope. With lockedAt non-nil only the rows locked at that instant.

func (*LoginThrottleSQL) LoadLoginThrottles added in v0.50.0

func (p *LoginThrottleSQL) LoadLoginThrottles(ctx context.Context, since time.Time, limit int) ([]*model.LoginThrottle, error)

LoadLoginThrottles returns the counters that still have something to say at since — a failure at or after it, or a lock that runs past it (exactly the rows PruneLoginThrottles(since) keeps) — the most recently active first, at most limit of them (<= 0: all). Unlike ListLoginThrottles it has no ceiling of its own: it is read once, at start, to fill the limiter's memory, and the limiter passes the size of that memory.

func (*LoginThrottleSQL) PruneLoginThrottles added in v0.50.0

func (p *LoginThrottleSQL) PruneLoginThrottles(ctx context.Context, before time.Time) (int64, error)

PruneLoginThrottles deletes counters that have had nothing to say since before: no failure after it and no lock still running. It answers how many.

func (*LoginThrottleSQL) SaveLoginThrottle added in v0.50.0

func (p *LoginThrottleSQL) SaveLoginThrottle(ctx context.Context, t *model.LoginThrottle) error

SaveLoginThrottle writes a counter, creating the row on first sight. The (scope, subject) pair is the key; ID is ignored.

⚠ UPDATE-then-INSERT and not an upsert, because the two upsert spellings differ per engine and this file is written once. Two writers racing on a brand-new subject can both reach the INSERT; the loser's unique-key error is answered by looking the row up — it exists, the other writer's count stands.

type NodeAgg

type NodeAgg struct {
	ID       int64
	ParentID *int64
	IsDir    bool
	Size     int64
	// Mtime is the node's backend_mtime (nullable). For folders it feeds the
	// "last activity" date = newest descendant mtime (see RecomputeFolderSizes).
	Mtime *time.Time
}

type NodeDeletedBySQL added in v0.47.0

type NodeDeletedBySQL struct {
	Pool *sql.DB
	// Placeholders turns `?` into the engine's own (PostgreSQL:
	// DollarPlaceholders). Nil for SQLite and MySQL.
	Placeholders func(q string) string
}

NodeDeletedBySQL implements Store.SetNodeDeletedBy (migration 00061), written ONCE for every engine. Each driver embeds a *NodeDeletedBySQL in its Store, so the method is promoted and there is no per-driver copy to drift — the arrangement of StorageOrderSQL and CatalogueFolderSQL (lesson #499). PR #64 wrote it twice, identical but for the placeholders, and only the duplication gate's driver-pair entry kept that green.

func (*NodeDeletedBySQL) SetNodeDeletedBy added in v0.47.0

func (o *NodeDeletedBySQL) SetNodeDeletedBy(ctx context.Context, nodeID int64, by *int64) error

SetNodeDeletedBy names who put a trashed row in the trash — the row, and for a folder in the trash every trashed row under its path that names nobody yet: its contents, which SoftDeleteAndRetag moved under the same trash key. A live row is left alone, and so is a row another delete already named.

⚠ The contents only when the folder's path IS a trash key (syspath.InTrash). A folder soft-deleted where it stood — a driver that could not trash it, so its bytes were deleted outright and its row kept its path — took none of its rows along: what is trashed under that path is older tombstones (the scanner found those files gone, nobody deleted them), and naming this person on them credited them with deletes they never made.

⚠ The subtree is matched with SUBSTR on the CHARACTER count, not LIKE: a `%` or `_` in a folder name is data, and see PrefixChars for why len() is wrong.

type NodeLinkStateSQL added in v0.54.0

type NodeLinkStateSQL struct {
	Pool *sql.DB
	// Placeholders turns `?` into the engine's own (PostgreSQL:
	// DollarPlaceholders). Nil for SQLite and MySQL.
	Placeholders func(q string) string
}

NodeLinkStateSQL implements Store.SetNodeLinkState and Store.NodeLinkStates (migration 00098), written ONCE for every engine. Each driver embeds a *NodeLinkStateSQL in its Store, so the methods are promoted and there is no per-driver copy to drift (the arrangement of NodeUnavailableSQL).

A symlink row (model.NodeTypeSymlink) is a link the storage's driver will not follow, and the driver says why when it lists it (storage.MetaLinkState: outside_root, broken, unresolved). The sync records that reason here, so a listing answered by the catalogue says what a listing read from the storage says - not just "a link", which is all the row's type can tell.

⚠ The column is read only through NodeLinkStates, for the rows a listing is about to return; it is NOT in the drivers' common node column list. Several upgrade tests build a catalogue at an older schema with the ordinary store methods (CreateNode, ListNodesUnder) before migrating it, and a column every node read and write names would make each of them fail on a database that does not have it yet.

func (*NodeLinkStateSQL) NodeLinkStates added in v0.54.0

func (o *NodeLinkStateSQL) NodeLinkStates(ctx context.Context, ids []int64) (map[int64]string, error)

NodeLinkStates returns the recorded reason of every row in ids that carries one. A row with none (not a link, catalogued before migration 00098, or a driver that gave no reason) is absent from the map.

func (*NodeLinkStateSQL) SetNodeLinkState added in v0.54.0

func (o *NodeLinkStateSQL) SetNodeLinkState(ctx context.Context, id int64, state string) (bool, error)

SetNodeLinkState records state as the reason row id is a link filex will not follow, and reports whether that changed the row. An empty state clears it: the driver listed the link and gave no reason. Nothing is written when the row already carries the same reason - the sync asks on every pass.

type NodeUnavailableSQL added in v0.50.0

type NodeUnavailableSQL struct {
	Pool *sql.DB
	// Placeholders turns `?` into the engine's own (PostgreSQL:
	// DollarPlaceholders). Nil for SQLite and MySQL.
	Placeholders func(q string) string
}

NodeUnavailableSQL implements Store.MarkNodeUnavailable, Store.ClearNodeUnavailable and Store.UnavailableAt (migration 00078, issue #104), written ONCE for every engine. Each driver embeds a *NodeUnavailableSQL in its Store, so the methods are promoted and there is no per-driver copy to drift (the arrangement of NodeDeletedBySQL).

An "unavailable" row is a live row the storage could not answer for: the sync asked whether its object still exists and got neither "yes" nor "not found". It stays in the catalogue, is listed with a warning, and nothing may be done to it or below it until the storage answers again.

func (*NodeUnavailableSQL) ClearNodeUnavailable added in v0.50.0

func (o *NodeUnavailableSQL) ClearNodeUnavailable(ctx context.Context, id int64) (bool, error)

ClearNodeUnavailable lifts the mark from row id and reports whether it carried one.

func (*NodeUnavailableSQL) MarkNodeUnavailable added in v0.50.0

func (o *NodeUnavailableSQL) MarkNodeUnavailable(ctx context.Context, id int64, reason string) (bool, error)

MarkNodeUnavailable records reason on the live row id and reports whether that changed anything: false when the row already carried the same reason (the sync asks on every pass, and only a change is worth a log line), or is not a live row.

func (*NodeUnavailableSQL) UnavailableAt added in v0.50.0

func (o *NodeUnavailableSQL) UnavailableAt(ctx context.Context, storageID int64, rel string) (*UnavailableEntry, error)

UnavailableAt answers whether rel, on storageID, is unavailable: whether its own live row, or the live row of any folder above it, carries a mark. The shallowest such row is returned, nil when there is none.

One indexed query, whatever the depth: every ancestor is looked up by its path hash ((storage_id, path_hash) is unique for live rows), which is also how both spellings a path column carries ("/a/b", "a/b") are matched.

type NodesUnderSQL added in v0.52.0

type NodesUnderSQL struct {
	Pool *sql.DB
	// Placeholders turns `?` into the engine's own (PostgreSQL:
	// DollarPlaceholders). Nil for SQLite and MySQL.
	Placeholders func(q string) string
	// Time binds a timestamp the way the engine compares nodes.seen_at:
	// CatalogueTime where it is compared as text, PlainTime on PostgreSQL.
	Time func(*time.Time) any
	// Columns is the driver's node column list and Scan reads one row of it.
	Columns string
	Scan    func(RowScanner) (*model.Node, error)
	// Path is nodes.path as this engine compares it BYTE FOR BYTE. Empty means
	// `path`: the column is byte-ordered itself on SQLite, and on MySQL since
	// 00041 (utf8mb4_0900_bin). PostgreSQL passes `path COLLATE "C"`: its
	// column has the database's collation, under which a range of names is not
	// a range of bytes: "/müşteri/x" sorts inside "/Müşteri/"…"/Müşteri0"
	// under ICU en-US, and "/Rapor/a.txt" outside "/Rapor/"…"/Rapor0" under
	// glibc en_US.utf8.
	Path string
	// Key wraps an SQL operand - the path column or a bound of the range - in
	// the expression the engine's index holds instead of the path: its first
	// characters, byte ordered. Nil where the index is on the column: SQLite
	// holds the whole path, and MySQL enters its path(512) index with a
	// comparison on path. PostgreSQL has neither. A B-tree row there stops at
	// 2704 bytes, so an index on the whole path would make filex refuse a path
	// the column takes, and one on left(path, 512) is entered only by a
	// comparison on that expression. The statements then ask Key("path") for
	// the range cut to the key's length - the bounds cut by the same
	// expression, on the server - and path for the range itself.
	Key func(of string) string
}

NodesUnderSQL implements what a Store answers about the rows at and below a folder - ListNodesUnder, ListStaleNodesUnder, CountLiveNodesUnder and HasLiveNodesUnder - written ONCE for every engine. Each driver embeds a *NodesUnderSQL in its Store, so the methods are promoted and there is no per-driver copy to drift (the arrangement of VanishedSQL).

"Below dir" is a BYTE RANGE of nodes.path (subtreeRange), one per spelling a stored path can have ("/a/b/…" and "a/b/…"), and the index of migration 00083 answers it. Until then it was SUBSTR(path,1,n)=?, which no index answers: the listing and the count read every row of the storage, and the stale rows every row of it last seen before the cut-off - in a rescan, every row outside the folder.

⚠ One statement arm per spelling, never an OR between the two. With an OR SQLite - which has no statistics here, nothing runs ANALYZE - plans the listing and the stale rows as before: nodes entered by storage_id alone, or by (storage_id, seen_at), never by the folder. TestSubtreeQuestionsAreAnsweredFromThePathIndexOnEveryEngine reads the plan of every statement below, on every engine.

⚠ Not every subtree match is here. Trashing and restoring a folder and naming who deleted it (retagTrashedSubtree, restoreTrashedSubtree, SetNodeDeletedBy) still match with SUBSTR and still read the storage's rows.

func (*NodesUnderSQL) CountLiveNodesUnder added in v0.52.0

func (o *NodesUnderSQL) CountLiveNodesUnder(ctx context.Context, storageID int64, dir string) (int64, error)

CountLiveNodesUnder implements Store.CountLiveNodesUnder.

func (*NodesUnderSQL) HasLiveNodesUnder added in v0.52.0

func (o *NodesUnderSQL) HasLiveNodesUnder(ctx context.Context, storageID int64, dir string) (bool, error)

HasLiveNodesUnder implements Store.HasLiveNodesUnder.

func (*NodesUnderSQL) ListNodesUnder added in v0.52.0

func (o *NodesUnderSQL) ListNodesUnder(ctx context.Context, storageID int64, dir string, includeDeleted bool) ([]*model.Node, error)

ListNodesUnder implements Store.ListNodesUnder.

func (*NodesUnderSQL) ListStaleNodesUnder added in v0.52.0

func (o *NodesUnderSQL) ListStaleNodesUnder(ctx context.Context, storageID int64, dir string, before time.Time) ([]*model.Node, error)

ListStaleNodesUnder implements Store.ListStaleNodesUnder.

type OIDCIdentitySQL added in v0.50.0

type OIDCIdentitySQL struct {
	Pool *sql.DB
	// Placeholders turns `?` into the engine's own (PostgreSQL:
	// DollarPlaceholders). Nil for SQLite and MySQL.
	Placeholders func(q string) string
	// GetUser reads one account with the driver's own column list.
	GetUser func(ctx context.Context, id int64) (*model.User, error)
}

OIDCIdentitySQL implements the account lookups and writes an OIDC sign-in makes (migration 00079, docs/SSO.md "Which account an SSO sign-in opens"), written ONCE for every engine. Each driver embeds a *OIDCIdentitySQL in its Store, so the methods are promoted and there is no per-driver copy to drift.

⚠⚠ Every lookup here stays inside ONE tenant (UserTenant). An OIDC sign-in used to find its account by e-mail across the whole platform (Store.GetUserByEmail), so an identity provider bound to one tenant could sign a person in to another tenant's account that had the same address. There is deliberately no unscoped variant.

func (*OIDCIdentitySQL) ClearUserOIDCIdentity added in v0.50.0

func (o *OIDCIdentitySQL) ClearUserOIDCIdentity(ctx context.Context, userID int64) (bool, error)

ClearUserOIDCIdentity removes an account's SSO bind (issuer and subject), and reports whether it had one: its next SSO sign-in is matched by its address again, as a first sign-in is (docs/SSO.md, "Removing an account's SSO bind"). An administrator's repair when the identity provider gave the person a new identity, or a new issuer address.

func (*OIDCIdentitySQL) GetUserByOIDCIdentity added in v0.50.0

func (o *OIDCIdentitySQL) GetUserByOIDCIdentity(ctx context.Context, t UserTenant, issuer, subject string) (*model.User, error)

GetUserByOIDCIdentity is the account of the tenant bound to the identity (issuer, subject), or (nil, nil) when none is. The comparison is exact on every engine: an identity provider's `sub` is case-sensitive (on MySQL the columns are utf8mb4_bin for it, migration 00079).

func (*OIDCIdentitySQL) GetUserInTenantByEmail added in v0.50.0

func (o *OIDCIdentitySQL) GetUserInTenantByEmail(ctx context.Context, t UserTenant, email string) (*model.User, error)

GetUserInTenantByEmail is the account of the tenant with this e-mail address, or (nil, nil) when the tenant has none: an account of another tenant with the address is not found.

func (*OIDCIdentitySQL) SetProviderOIDCTrustEmail added in v0.50.0

func (o *OIDCIdentitySQL) SetProviderOIDCTrustEmail(ctx context.Context, providerID int64, trust bool) error

SetProviderOIDCTrustEmail is a tenant's own OIDC's "trust this provider's email addresses" (providers.oidc_trust_email, migration 00079). Its own statement: UpdateProvider never writes it, so a caller that rebuilds a provider from a form cannot switch it by accident.

func (*OIDCIdentitySQL) SetUserDisabledReason added in v0.50.0

func (o *OIDCIdentitySQL) SetUserDisabledReason(ctx context.Context, userID int64, reason string) error

SetUserDisabledReason switches an account off and records why the server did (model.DisabledPendingApproval). An administrator switching the account on or off (Store.SetUserEnabled) clears the reason.

func (*OIDCIdentitySQL) SetUserOIDCIdentity added in v0.50.0

func (o *OIDCIdentitySQL) SetUserOIDCIdentity(ctx context.Context, userID int64, issuer, subject string) error

SetUserOIDCIdentity binds an account to the identity (issuer, subject). The unique index (provider_id, oidc_issuer, oidc_subject) refuses a second account of the same tenant bound to the same identity.

type OfficeSessionSQL added in v0.53.0

type OfficeSessionSQL struct {
	Pool *sql.DB
	// Placeholders turns `?` into the engine's own (PostgreSQL:
	// DollarPlaceholders). Nil for SQLite and MySQL.
	Placeholders func(q string) string
}

OfficeSessionSQL implements the office_sessions half of Store (migration 00092, internal/onlyoffice session_base.go, #184), written ONCE for every engine and embedded in each driver's Store - the arrangement of LoginThrottleSQL.

Every column is an integer or text: the modification time is Unix nanoseconds and the expiry Unix seconds, so nothing here depends on how an engine stores or compares a timestamp, and a value comes back exactly as it went in.

func (*OfficeSessionSQL) AddOfficeSession added in v0.53.0

func (p *OfficeSessionSQL) AddOfficeSession(ctx context.Context, s *model.OfficeSession, now time.Time) (*model.OfficeSession, error)

AddOfficeSession records s unless an unexpired row for its key is already there, and returns the row that stands - the FIRST writer's. An expired row is replaced. now is the clock the expiry is read against.

It is how a session's base is recorded: a second person handed the same key (or a second instance asked for it) joins the running session, which shows them ITS version, so the first record must stand.

func (*OfficeSessionSQL) DeleteOfficeSession added in v0.53.0

func (p *OfficeSessionSQL) DeleteOfficeSession(ctx context.Context, key string) error

DeleteOfficeSession removes the row for key (the session is over).

func (*OfficeSessionSQL) GetOfficeSession added in v0.53.0

func (p *OfficeSessionSQL) GetOfficeSession(ctx context.Context, key string) (*model.OfficeSession, error)

GetOfficeSession returns the session with this key, or (nil, nil) when there is none. An expired row is returned as it is: the caller decides (ExpiresUnix), and the prune removes it.

func (*OfficeSessionSQL) PruneOfficeSessions added in v0.53.0

func (p *OfficeSessionSQL) PruneOfficeSessions(ctx context.Context, before time.Time) (int64, error)

PruneOfficeSessions removes the rows that expired before `before`; n = rows removed.

func (*OfficeSessionSQL) PutOfficeSession added in v0.53.0

func (p *OfficeSessionSQL) PutOfficeSession(ctx context.Context, s *model.OfficeSession) error

PutOfficeSession creates the row for s.DocKey or overwrites it.

⚠ UPDATE-then-INSERT and not an upsert, because the upsert spellings differ per engine and this file is written once. The loser of two writers racing on a new key reaches the INSERT, gets the unique-key error, and writes its row with a second UPDATE: the last writer's row stands, as an upsert's would.

type PluginRequestSQL added in v0.49.0

type PluginRequestSQL struct {
	Pool *sql.DB
	// Placeholders turns `?` into the engine's own (PostgreSQL:
	// DollarPlaceholders). Nil for SQLite and MySQL.
	Placeholders func(q string) string
	// Time binds a timestamp: CatalogueTime for SQLite and MySQL (they compare
	// stored timestamps as text), PlainTime for PostgreSQL. Nil = CatalogueTime.
	Time func(t *time.Time) any
}

PluginRequestSQL implements the plugin_requests half of Store (migration 00070, internal/pluginreq), written ONCE for every engine and embedded in each driver's Store — the arrangement of DraftSQL, for the same reason (the duplication gate in web/tests/quality).

It only reads and writes rows; what a request means, and when one may move from one state to another, is internal/pluginreq's.

func (*PluginRequestSQL) CreatePluginRequest added in v0.49.0

func (p *PluginRequestSQL) CreatePluginRequest(ctx context.Context, in *model.PluginRequest) (*model.PluginRequest, error)

CreatePluginRequest inserts a request and returns it as the store reads it back. A request without a key is given one.

func (*PluginRequestSQL) GetPluginRequest added in v0.49.0

func (p *PluginRequestSQL) GetPluginRequest(ctx context.Context, id int64) (*model.PluginRequest, error)

GetPluginRequest returns one request, sql.ErrNoRows when there is none.

func (*PluginRequestSQL) ListPluginRequests added in v0.49.0

func (p *PluginRequestSQL) ListPluginRequests(ctx context.Context, status string, limit int) ([]*model.PluginRequest, error)

ListPluginRequests returns the requests in one state ("" = every state), the newest first, at most limit of them (<= 0: 500).

func (*PluginRequestSQL) PendingPluginRequestBySource added in v0.49.0

func (p *PluginRequestSQL) PendingPluginRequestBySource(ctx context.Context, sourceKey string) (*model.PluginRequest, error)

PendingPluginRequestBySource returns the pending request for a source key, sql.ErrNoRows when there is none. The newest wins if a race left two.

func (*PluginRequestSQL) UpdatePluginRequest added in v0.49.0

func (p *PluginRequestSQL) UpdatePluginRequest(ctx context.Context, r *model.PluginRequest, onlyIfPending bool) (bool, error)

UpdatePluginRequest writes a request's decision fields — status, who decided and when, the note and the result. With onlyIfPending the write happens only while the stored row is still pending, and ok reports whether it did: two administrators closing one request cannot both win.

type PushSQL added in v0.54.0

type PushSQL struct {
	Pool *sql.DB
	// Placeholders turns `?` into the engine's own (PostgreSQL:
	// DollarPlaceholders). Nil for SQLite and MySQL.
	Placeholders func(q string) string
}

PushSQL implements the Web Push half of Store (task #191, internal/notify push.go): the devices people receive pushes on, and the instance's VAPID key. Written ONCE for every engine and embedded in each driver's Store - the arrangement of DigestSQL.

It only reads and writes rows: what is pushed to whom is internal/notify's.

⚠ No upsert (three engines, three spellings) and no timestamp is ever bound: every time written here is the database's own CURRENT_TIMESTAMP.

func (*PushSQL) AdvancePushMark added in v0.54.0

func (p *PushSQL) AdvancePushMark(ctx context.Context, id, from, to int64) (bool, error)

AdvancePushMark moves a device's mark from `from` to `to` and answers whether it did: false when the mark is no longer `from` (another server, or another pass, pushed these rows first). Compare-and-set, so a row is pushed to a device once however many servers try. ⚠ Called with to > from only, so the row always changes and MySQL's "changed rows" count is right.

func (*PushSQL) CountPushSubscriptions added in v0.54.0

func (p *PushSQL) CountPushSubscriptions(ctx context.Context) (int64, error)

CountPushSubscriptions is how many devices there are, everybody's.

func (*PushSQL) CreatePushVAPIDKey added in v0.54.0

func (p *PushSQL) CreatePushVAPIDKey(ctx context.Context, k *model.PushVAPIDKey) error

CreatePushVAPIDKey stores the first key. It fails when there is one already (two servers started at once): the caller reads the one that won.

func (*PushSQL) DeletePushSubscription added in v0.54.0

func (p *PushSQL) DeletePushSubscription(ctx context.Context, userID, id int64) (bool, error)

DeletePushSubscription removes one of userID's own devices; ok is false when there was no such device OF THEIRS (somebody else's id is answered exactly like an id that does not exist).

func (*PushSQL) DeletePushSubscriptionByHash added in v0.54.0

func (p *PushSQL) DeletePushSubscriptionByHash(ctx context.Context, userID int64, hash string) (bool, error)

DeletePushSubscriptionByHash removes userID's device with this endpoint hash (the browser that is turning push off, or signing out).

func (*PushSQL) DropPushSubscription added in v0.54.0

func (p *PushSQL) DropPushSubscription(ctx context.Context, id int64) error

DropPushSubscription removes a device by id, whoever's it is: the push service said it is gone, or it refused too many pushes in a row.

func (*PushSQL) GetPushVAPIDKey added in v0.54.0

func (p *PushSQL) GetPushVAPIDKey(ctx context.Context) (*model.PushVAPIDKey, error)

GetPushVAPIDKey answers the instance's VAPID key, nil when none was made.

func (*PushSQL) ListPushSubscribers added in v0.54.0

func (p *PushSQL) ListPushSubscribers(ctx context.Context) ([]int64, error)

ListPushSubscribers answers every person with at least one device.

func (*PushSQL) ListPushSubscriptions added in v0.54.0

func (p *PushSQL) ListPushSubscriptions(ctx context.Context, userID int64) ([]*model.PushSubscription, error)

ListPushSubscriptions answers one person's devices, the oldest first.

func (*PushSQL) RecordPushResult added in v0.54.0

func (p *PushSQL) RecordPushResult(ctx context.Context, id int64, taken bool) (int, error)

RecordPushResult notes the push service's answer to a push to device id: taken (the refusal count goes back to 0) or refused (it goes up). It answers the count after the write.

func (*PushSQL) ReplacePushVAPIDKey added in v0.54.0

func (p *PushSQL) ReplacePushVAPIDKey(ctx context.Context, k *model.PushVAPIDKey) (int64, error)

ReplacePushVAPIDKey puts k in place of the key and forgets every device - a subscription is bound to the key it was made with, and a push signed with another is refused. It answers how many devices were forgotten. Run it in a transaction (Store.WithTx): the key and the devices change together.

func (*PushSQL) SavePushSubscription added in v0.54.0

func (p *PushSQL) SavePushSubscription(ctx context.Context, s *model.PushSubscription) (*model.PushSubscription, error)

SavePushSubscription records s (keyed by s.EndpointHash) and answers the row as stored. The same person saving the same device again keeps its row and its mark, with the keys and the label replaced; a device another person had (a second account signed in on the same browser) is theirs no longer - the row is replaced, so nothing of the first person's is pushed to it again.

type Querier added in v0.45.0

type Querier interface {
	ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
	QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
	QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}

Querier is what one statement needs. *sql.DB and *sql.Tx are both one.

func Conn added in v0.45.0

func Conn(ctx context.Context, pool *sql.DB) Querier

Conn returns the transaction ctx carries on pool, or pool itself. A transaction on another database is not this one's business.

type RecentSearchSQL added in v0.53.0

type RecentSearchSQL struct {
	Pool *sql.DB
	// Placeholders turns `?` into the engine's own (PostgreSQL:
	// DollarPlaceholders). Nil for SQLite and MySQL.
	Placeholders func(q string) string
}

RecentSearchSQL implements the recent-searches half of Store (migration 00090, task #168), written ONCE for every engine and embedded in each driver's Store - the arrangement of DraftSQL and LoginThrottleSQL.

The order of a person's list is the row id: searching the same words again deletes the old row and writes a new one, so "newest first" is `id DESC` on every engine, with no timestamp resolution to tie on (SQLite's CURRENT_TIMESTAMP is whole seconds, and two searches a second apart are an ordinary thing to type).

func (*RecentSearchSQL) AddRecentSearch added in v0.53.0

func (p *RecentSearchSQL) AddRecentSearch(ctx context.Context, userID int64, surface, query string, keep int) (*model.RecentSearch, error)

AddRecentSearch records that userID searched for query on surface: the row for the same words goes, a new one is written at the top, and the list is cut to the newest `keep`. It answers the new row.

⚠ Delete-then-insert rather than an upsert, because the two upsert spellings differ per engine and this file is written once, and because the new id IS the new position. Two writers racing on the same words can leave two rows for a moment; the next write of those words removes both.

func (*RecentSearchSQL) ClearRecentSearches added in v0.53.0

func (p *RecentSearchSQL) ClearRecentSearches(ctx context.Context, userID int64, surface string) error

ClearRecentSearches empties userID's list on one surface.

func (*RecentSearchSQL) DeleteRecentSearch added in v0.53.0

func (p *RecentSearchSQL) DeleteRecentSearch(ctx context.Context, userID, id int64) (bool, error)

DeleteRecentSearch removes one row of userID's own list; ok is false when there was no such row OF THEIRS - somebody else's id is not theirs to remove, and is answered exactly like an id that does not exist.

func (*RecentSearchSQL) ListRecentSearches added in v0.53.0

func (p *RecentSearchSQL) ListRecentSearches(ctx context.Context, userID int64, surface string, limit int) ([]*model.RecentSearch, error)

ListRecentSearches answers one person's list on one surface, newest first, at most limit rows (limit <= 0: all of them).

type ReplicaInitialCopySQL added in v0.53.0

type ReplicaInitialCopySQL struct {
	Pool *sql.DB
	// Placeholders turns `?` into the engine's own (PostgreSQL:
	// DollarPlaceholders). Nil for SQLite and MySQL.
	Placeholders func(q string) string
}

ReplicaInitialCopySQL implements the replica_initial_copies half of Store (migration 00094, internal/replica initial.go, #186), written ONCE for every engine and embedded in each driver's Store - the arrangement of OfficeSessionSQL.

Every column is an integer or text: the times are Unix seconds, so nothing here depends on how an engine stores or compares a timestamp.

func (*ReplicaInitialCopySQL) ClaimReplicaInitialCopy added in v0.53.0

func (p *ReplicaInitialCopySQL) ClaimReplicaInitialCopy(ctx context.Context, storageID, targetID int64, owner string, now, until int64) (bool, error)

ClaimReplicaInitialCopy takes the row for one slice of work.

func (*ReplicaInitialCopySQL) DeleteReplicaInitialCopy added in v0.53.0

func (p *ReplicaInitialCopySQL) DeleteReplicaInitialCopy(ctx context.Context, storageID int64) error

DeleteReplicaInitialCopy removes the storage's row (it was unlinked, or the storage is gone).

func (*ReplicaInitialCopySQL) GetReplicaInitialCopy added in v0.53.0

func (p *ReplicaInitialCopySQL) GetReplicaInitialCopy(ctx context.Context, storageID int64) (*model.ReplicaInitialCopy, error)

GetReplicaInitialCopy returns the storage's row, or (nil, nil) when it has none.

func (*ReplicaInitialCopySQL) ListReplicaInitialCopies added in v0.53.0

func (p *ReplicaInitialCopySQL) ListReplicaInitialCopies(ctx context.Context) ([]*model.ReplicaInitialCopy, error)

ListReplicaInitialCopies returns every row, by storage.

func (*ReplicaInitialCopySQL) SaveReplicaInitialCopy added in v0.53.0

func (p *ReplicaInitialCopySQL) SaveReplicaInitialCopy(ctx context.Context, c *model.ReplicaInitialCopy, owner string) (bool, error)

SaveReplicaInitialCopy writes c's state while `owner` holds the row.

func (*ReplicaInitialCopySQL) StartReplicaInitialCopy added in v0.53.0

func (p *ReplicaInitialCopySQL) StartReplicaInitialCopy(ctx context.Context, storageID, targetID, now int64, restart bool) (*model.ReplicaInitialCopy, bool, error)

StartReplicaInitialCopy makes the storage's row a fresh copy to targetID, unless a row for that target is already there and restart is false.

⚠ UPDATE-then-INSERT and not an upsert, because the upsert spellings differ per engine and this file is written once. Two callers racing on a storage with no row: the loser's INSERT meets the primary key, and the row the winner wrote is the one returned.

type ReplicaLinkSQL added in v0.53.0

type ReplicaLinkSQL struct {
	Pool *sql.DB
	// Placeholders turns `?` into the engine's own (PostgreSQL:
	// DollarPlaceholders). Nil for SQLite and MySQL.
	Placeholders func(q string) string
}

ReplicaLinkSQL implements the replica_links half of Store (migration 00095, internal/replica folder.go, #186): the folder each storage writes into on its replication target. Written ONCE for every engine and embedded in each driver's Store, like ReplicaInitialCopySQL.

func (p *ReplicaLinkSQL) DeleteReplicaLink(ctx context.Context, storageID int64) error

DeleteReplicaLink removes the storage's row (it was unlinked, relinked elsewhere, or deleted).

func (p *ReplicaLinkSQL) GetReplicaLink(ctx context.Context, storageID int64) (*model.ReplicaLink, error)

GetReplicaLink returns the storage's row, or (nil, nil) when it has none.

func (p *ReplicaLinkSQL) ListReplicaLinks(ctx context.Context) ([]*model.ReplicaLink, error)

ListReplicaLinks returns every row, by storage.

func (p *ReplicaLinkSQL) PutReplicaLink(ctx context.Context, l *model.ReplicaLink) error

PutReplicaLink creates or replaces the storage's row.

⚠ DELETE-then-INSERT in one transaction, not an upsert: the upsert spellings differ per engine and this file is written once, and an UPDATE that changes nothing reads as "no row" on MySQL. The unique key on (target_id, folder_key) refuses a folder another storage on the target holds; the transaction then leaves the old row in place.

type RowScanner added in v0.44.2

type RowScanner interface {
	Scan(dst ...any) error
}

RowScanner is the one method ScanCatalogueFolder needs from *sql.Row or *sql.Rows.

type ShareWithMeta

type ShareWithMeta struct {
	Share        *model.Share `json:"share"`
	CreatorEmail string       `json:"creator_email,omitempty"`
	// CreatorName is the creator as every screen names a person
	// (model.PersonLabel), filled by the admin handler — never by the store.
	CreatorName string `json:"creator_name,omitempty"`
	NodePath    string `json:"node_path,omitempty"`
	StorageName string `json:"storage_name,omitempty"`
	// PluginName names the app that opened this link, when one did (00046).
	// Empty for an ordinary share. It is joined in rather than derived by the
	// caller so the admin Shares table can carry a "plugin / page" column
	// without a lookup per row.
	PluginName string `json:"plugin_name,omitempty"`
	// URL is the canonical public link (`<origin>/s/<token>`), filled by the
	// admin handler from the configured public origin — never by the store.
	// The admin Shares page used to build it from the browser's address, so an
	// operator signed in on localhost copied a localhost link (issue #32).
	URL string `json:"url,omitempty"`
	// App says what an app's link IS (its page's declared purpose), filled
	// by the handlers from the app's manifest — never by the store. Nil for
	// an ordinary share and for a page that declares no purpose.
	App *AppLink `json:"app,omitempty"`
}

ShareWithMeta is the admin-list row that joins shares + creator email + node path + storage name so the admin UI doesn't have to issue N follow-up queries.

type StorageOrderSQL added in v0.46.0

type StorageOrderSQL struct {
	Pool *sql.DB
	// Placeholders turns `?` into the engine's own (PostgreSQL:
	// DollarPlaceholders). Nil for SQLite and MySQL.
	Placeholders func(q string) string
}

StorageOrderSQL implements Store.SetStorageOrder (migration 00060, issue #57), written ONCE for every engine. Each driver embeds a *StorageOrderSQL in its Store, so the method is promoted and there is no per-driver copy to drift (the same arrangement as CatalogueFolderSQL, and for the same reason: the duplication gate in web/tests/quality).

func (*StorageOrderSQL) SetStorageOrder added in v0.46.0

func (o *StorageOrderSQL) SetStorageOrder(ctx context.Context, ordered []int64, cleared []int64) error

SetStorageOrder gives ordered[i] position i+1 and clears every id in cleared, in one transaction (or in the caller's, when ctx carries one on this database), so no reader ever sees half an order. An id that names no row changes nothing; the caller decides which ids are valid.

⚠ Clearing runs first, so an id in both lists ends up placed.

type Store

type Store interface {
	// Lifecycle
	Ping(ctx context.Context) error
	Close() error

	// WithTx runs fn in ONE transaction: every statement a Store method runs
	// with the context fn is handed is part of it — through the wrappers
	// (quotastore, identitystore) too, since they pass the context on. It
	// commits when fn returns nil and rolls back otherwise. See tx.go for
	// what must NOT run inside it (anything reaching the database through
	// another handle, the job queue above all).
	WithTx(ctx context.Context, fn func(ctx context.Context) error) error

	// Storages
	CreateStorage(ctx context.Context, s *model.Storage) (*model.Storage, error)
	GetStorage(ctx context.Context, id int64) (*model.Storage, error)
	GetStorageByName(ctx context.Context, name string) (*model.Storage, error)
	// GetStorageByUID resolves a storage by the identifier it keeps for life.
	// The file protocols accept it in place of the name so a mount survives a
	// rename; see model.Storage.UID.
	GetStorageByUID(ctx context.Context, uid string) (*model.Storage, error)
	// ListStorages and ListEnabledStorages list in the admin's order: placed
	// storages by sort_order, then unplaced ones by id (model.Storage.SortOrder).
	ListStorages(ctx context.Context) ([]*model.Storage, error)
	ListEnabledStorages(ctx context.Context) ([]*model.Storage, error)
	// UpdateStorage writes every column the edit form owns — not sort_order.
	UpdateStorage(ctx context.Context, s *model.Storage) error
	UpdateStorageSyncCursor(ctx context.Context, id int64, at time.Time, token string) error
	DeleteStorage(ctx context.Context, id int64) error
	// SetStorageOrder places ordered[i] at position i+1 and clears (NULL) the
	// position of every id in cleared, in one transaction (issue #57;
	// StorageOrderSQL). It does not check the ids: the caller decides which
	// storages it may order.
	SetStorageOrder(ctx context.Context, ordered []int64, cleared []int64) error

	// Nodes
	CreateNode(ctx context.Context, n *model.Node) (*model.Node, error)
	GetNode(ctx context.Context, id int64) (*model.Node, error)
	GetNodeByPath(ctx context.Context, storageID int64, pathHash string) (*model.Node, error)
	// GetNodeByPathIncludingDeleted answers "is there ANY row at this path",
	// live or trashed. The sync worker asks it so that an object appearing
	// where a trashed row still sits is reported rather than silently
	// conflated with it.
	//
	// Since migration 00032 the unique index over (storage_id, path_hash) is
	// partial on live rows, so several trashed rows may share a path with at
	// most one live one. Implementations MUST return the live row when there
	// is one, and otherwise the most recently trashed row -- an arbitrary
	// pick would make the sync's decision depend on row order.
	GetNodeByPathIncludingDeleted(ctx context.Context, storageID int64, pathHash string) (*model.Node, error)
	// ListLiveNodesInTrash returns every LIVE node of a storage whose path is
	// the trash bucket or sits inside it. Nothing should ever be live in
	// there: it is either a trashed item an older sync worker un-deleted, or
	// a row that same worker minted for the trash's own bytes. The sync pass
	// uses it to clear both up. trashPrefix is given without a leading slash
	// (`trash.Prefix`); implementations match the stored path with and
	// without one, because drivers differ on that.
	ListLiveNodesInTrash(ctx context.Context, storageID int64, trashPrefix string) ([]*model.Node, error)
	// ListNodesUnder returns the row at dir and every row below it, in both
	// path spellings drivers produce ("/a/b" and "a/b"), in id order;
	// includeDeleted adds soft-deleted rows. The match is EXACT: names compare
	// byte for byte, so a folder of another case, a sibling that only starts
	// with its name and a `%` or `_` read as a wildcard never match, and a
	// folder whose name is not ASCII matches as reliably as one that is. The
	// storage root ("" or "/") is never a subtree and returns nothing.
	//
	// "Below dir" is a byte range of path that an index answers (migration
	// 00083, NodesUnderSQL), here and in the three questions further down
	// that are matched the same way: what they cost follows the folder's
	// rows, not the storage's.
	ListNodesUnder(ctx context.Context, storageID int64, dir string, includeDeleted bool) ([]*model.Node, error)
	ListNodesByParent(ctx context.Context, storageID int64, parentID *int64) ([]*model.Node, error)
	// AggNodes returns a lightweight {id, parent_id, is_dir, size} row for every
	// live node of a storage — the input to folder-size aggregation.
	AggNodes(ctx context.Context, storageID int64) ([]NodeAgg, error)
	// SetNodeSize overwrites a node's cached size. Used to store recursive folder
	// totals (see internal/sync.RecomputeFolderSizes).
	SetNodeSize(ctx context.Context, id int64, size int64) error
	// SetNodeMtime overwrites a node's cached backend_mtime. Used to give folders
	// a "last activity" date (newest descendant mtime) so the explorer can show a
	// date for directories whose driver reports none (e.g. synthetic S3 prefixes).
	SetNodeMtime(ctx context.Context, id int64, mtime *time.Time) error
	UpdateNodeMeta(ctx context.Context, id int64, size int64, mime, etag string, mtime time.Time) error
	TouchNodeSeen(ctx context.Context, id int64) error
	SoftDeleteNode(ctx context.Context, id int64) error
	// SoftDeleteAndRetag flips deleted_at + rewrites path/path_hash to a
	// trash key in one shot, while saving the original path in
	// storage_key. Used by vfDelete after the on-disk rename so the
	// original-path slot is freed (UNIQUE(storage_id, path_hash)).
	SoftDeleteAndRetag(ctx context.Context, id int64, trashPath, trashHash, origPath string) error
	HardDeleteNode(ctx context.Context, id int64) error
	MoveNode(ctx context.Context, id int64, parentID *int64, name, path, pathHash string) error
	ListStaleNodes(ctx context.Context, storageID int64, before time.Time) ([]*model.Node, error)
	// ListVanishedNodeIDs returns, above afterID and in id order, the ids of a
	// storage's rows soft-deleted WHERE THEY STOOD (deleted, path outside
	// `.filex-trash/`): rows whose bytes were never parked in the trash, which
	// the storage sync drops for good (issue #74). See VanishedSQL.
	ListVanishedNodeIDs(ctx context.Context, storageID, afterID int64, limit int) ([]int64, error)
	// CountChildRows counts the rows, live or deleted, whose parent_id is
	// parentID: what the parent_id cascade would take with that row.
	CountChildRows(ctx context.Context, parentID int64) (int, error)
	// MarkNodeUnavailable records that the storage could not answer for the
	// live row id (migration 00078, issue #104), and reports whether that
	// changed the row. See NodeUnavailableSQL.
	MarkNodeUnavailable(ctx context.Context, id int64, reason string) (bool, error)
	// ClearNodeUnavailable lifts that mark, reporting whether there was one.
	ClearNodeUnavailable(ctx context.Context, id int64) (bool, error)
	// UnavailableAt returns the row that makes rel unavailable - rel's own, or
	// a folder's above it - or nil.
	UnavailableAt(ctx context.Context, storageID int64, rel string) (*UnavailableEntry, error)
	// SetNodeLinkState records why row id is a link filex will not follow -
	// the driver's storage.MetaLinkState as the sync last listed it
	// (migration 00098) - and reports whether that changed the row; "" clears
	// it. See NodeLinkStateSQL.
	SetNodeLinkState(ctx context.Context, id int64, state string) (bool, error)
	// NodeLinkStates returns the recorded reason of every row in ids that has
	// one. The common node reads do not carry it (model.Node.LinkState): a
	// listing asks for its link rows' reasons with this.
	NodeLinkStates(ctx context.Context, ids []int64) (map[int64]string, error)
	// ListStaleNodesUnder is ListStaleNodes bounded to the rows strictly BELOW
	// dir (the folder's own row excluded), matched exactly as ListNodesUnder
	// matches — the tombstone candidates of a folder rescan.
	ListStaleNodesUnder(ctx context.Context, storageID int64, dir string, before time.Time) ([]*model.Node, error)
	// CountLiveNodesUnder counts the live rows strictly below dir — the
	// baseline a folder rescan's 70% guard compares what it saw against.
	CountLiveNodesUnder(ctx context.Context, storageID int64, dir string) (int64, error)
	// HasLiveNodesUnder reports whether a live row sits strictly below dir,
	// matched exactly as ListNodesUnder matches - what tells a new folder
	// from one that holds something. It is CountLiveNodesUnder > 0 for a
	// caller that does not need the number, answered by the first row found.
	// The storage root is never a subtree: false.
	HasLiveNodesUnder(ctx context.Context, storageID int64, dir string) (bool, error)
	CountNodesByStorage(ctx context.Context, storageID int64) (int64, error)

	// Replication targets — separate entity. Storages.replica_target_id
	// is the FK linking a primary to one of these.
	ListReplicationTargets(ctx context.Context) ([]*model.ReplicationTarget, error)
	GetReplicationTarget(ctx context.Context, id int64) (*model.ReplicationTarget, error)
	CreateReplicationTarget(ctx context.Context, rt *model.ReplicationTarget) (*model.ReplicationTarget, error)
	UpdateReplicationTarget(ctx context.Context, rt *model.ReplicationTarget) error
	DeleteReplicationTarget(ctx context.Context, id int64) error
	// StorageStats returns (file_count, total_size_bytes) for a storage,
	// excluding directories and soft-deleted nodes. Used by the admin
	// storages list page so each row can show "N files, 1.2 GB" without
	// the SPA looping every node row.
	StorageStats(ctx context.Context, storageID int64) (fileCount int64, totalBytes int64, err error)

	// ── Lazy catalogue: per-folder state (migration 00059) ──
	//
	// What a lazily catalogued storage knows about each of its folders
	// (docs/LAZY-CATALOGUE.md). Paths are canonical ("/" for the root,
	// "/a/b" below it) and keyed by pathkey.Hash like nodes.
	//
	// GetCatalogueFolder returns (nil, nil) when the folder has no row.
	GetCatalogueFolder(ctx context.Context, storageID int64, pathHash string) (*model.CatalogueFolder, error)
	// DiscoverCatalogueFolders records folders seen in a parent's listing as
	// uncatalogued; a folder that already has a row keeps it untouched.
	DiscoverCatalogueFolders(ctx context.Context, storageID int64, folders []model.CatalogueFolder) error
	// RecordCatalogueFolder writes every column of one folder's row.
	RecordCatalogueFolder(ctx context.Context, f *model.CatalogueFolder) error
	// SetCatalogueFolderWatch records a watch placed (watchedAt set: state
	// watched) or removed (nil: state catalogued) on a folder that has been
	// catalogued. An uncatalogued or missing row is left alone.
	SetCatalogueFolderWatch(ctx context.Context, storageID int64, pathHash string, watchedAt *time.Time, reconcileOnOpen bool) error
	// TouchCatalogueFolderVisit stamps visited_at on an existing row.
	TouchCatalogueFolderVisit(ctx context.Context, storageID int64, pathHash string, at time.Time) error
	// ListCatalogueFolders returns the rows a filter selects: by default the
	// shallowest first; with ReconciledBefore, catalogued (unwatched) rows
	// oldest first.
	ListCatalogueFolders(ctx context.Context, storageID int64, f model.CatalogueFolderFilter) ([]*model.CatalogueFolder, error)
	// CountCatalogueFolders counts a storage's rows per state.
	CountCatalogueFolders(ctx context.Context, storageID int64) (model.CatalogueCounts, error)
	// ResetCatalogueWatches demotes every watched row to catalogued with
	// reconcile_on_open set — the watches died with the previous process.
	ResetCatalogueWatches(ctx context.Context, storageID int64) (int64, error)
	// DeleteCatalogueFoldersUnder drops the folder's row and every row below
	// it (the folder is gone from the storage).
	DeleteCatalogueFoldersUnder(ctx context.Context, storageID int64, dir string) error
	// HasUncataloguedUnder reports whether any folder strictly below dir is
	// still uncatalogued ("/" asks about the whole storage).
	HasUncataloguedUnder(ctx context.Context, storageID int64, dir string) (bool, error)
	// SearchNodes returns up to `limit` live nodes of a storage whose NAME
	// answers m — every word in it, compared through internal/namefold (see
	// model.NameMatch) — ranked by m.Prefer before the LIMIT. It is the
	// search an install without the index runs, and every caller reaches it
	// through search.Fallback.Candidates. No words, no rows.
	//
	// ⚠ EVERY word is a condition, never one of them: the fallback used to
	// send only the longest word and check the rest in Go over the first rows
	// by name, and a word most files shared pushed the file past the LIMIT
	// (GitHub PR #46, measured on a 169k-file catalogue: the file at row
	// 33 623 of a 1 000-row window).
	//
	// ⚠ Rank BEFORE the limit, never after: a LIMIT on `ORDER BY name` keeps
	// the alphabetically first rows, and a search for `report` among a
	// thousand `a-report-…` files lost `report.txt` itself.
	//
	// ⚠ The stored name goes through the same normaliser as the words, spelt
	// in each dialect: fx_match/fx_rank (Go, registered on SQLite),
	// normalize+lower on PostgreSQL, and on MySQL, which can compose nothing,
	// its accent- and case-insensitive collation over both the composed and
	// the decomposed form of each word. TestSearchNodesOnEveryEngine holds
	// the three to one answer.
	SearchNodes(ctx context.Context, storageID int64, m model.NameMatch, limit int) ([]*model.Node, error)

	// Users
	CreateUser(ctx context.Context, email, passwordHash, role, locale, tz string) (*model.User, error)
	GetUser(ctx context.Context, id int64) (*model.User, error)
	GetUserByEmail(ctx context.Context, email string) (*model.User, error)
	// Dual-side login (migration 00025): an account answers to its e-mail or
	// its username. Reach these through identity.Resolve, not directly — the
	// rule for deciding which one an identifier is belongs in one place.
	GetUserByUsername(ctx context.Context, username string) (*model.User, error)
	SetUserUsername(ctx context.Context, id int64, username string) error
	// Multi-tenancy (docs/MULTI-TENANCY.md): look a user up within one provider
	// (tenant); re-home a user to a provider + record its OIDC subject (JIT).
	GetUserByProviderEmail(ctx context.Context, providerID int64, email string) (*model.User, error)
	// SetUserProvider re-homes a user. An empty oidcSubject also drops the
	// account's SSO identity (its issuer, migration 00079): an account moved
	// to another tenant is bound to nobody there.
	SetUserProvider(ctx context.Context, userID, providerID int64, oidcSubject string) error
	// The SSO identity an account is bound to (migration 00079, OIDCIdentitySQL):
	// lookups that never leave one tenant, the bind, and the server's own
	// reason for an account it opened switched off.
	GetUserByOIDCIdentity(ctx context.Context, t UserTenant, issuer, subject string) (*model.User, error)
	GetUserInTenantByEmail(ctx context.Context, t UserTenant, email string) (*model.User, error)
	SetUserOIDCIdentity(ctx context.Context, userID int64, issuer, subject string) error
	ClearUserOIDCIdentity(ctx context.Context, userID int64) (bool, error)
	SetProviderOIDCTrustEmail(ctx context.Context, providerID int64, trust bool) error
	SetUserDisabledReason(ctx context.Context, userID int64, reason string) error
	// SetUserAuthSource records where an account comes from (migration 00084):
	// model.AuthSourceLocal, SSO, LDAP or Proxy.
	SetUserAuthSource(ctx context.Context, userID int64, source string) error
	// SetUserAuthDirectory records which LDAP directory made an account
	// (its provider slug: "ldap", "ldap-2"…; migration 00084).
	SetUserAuthDirectory(ctx context.Context, userID int64, directory string) error
	// GetUserByDirectoryID finds the account a directory entry's permanent id
	// was recorded on (model.User.DirectoryID; migration 00085).
	GetUserByDirectoryID(ctx context.Context, directoryID string) (*model.User, error)
	// SetUserDirectoryID records a directory account's permanent id.
	SetUserDirectoryID(ctx context.Context, userID int64, directoryID string) error
	// SetUserEnabledByDirectory is SetUserEnabled for directory sync: switched
	// off, the account's disabled_reason says the directory did it
	// (model.DisabledByDirectory), so sync may undo it; switched on, the
	// reason is cleared.
	SetUserEnabledByDirectory(ctx context.Context, userID int64, enabled bool) error
	// SetUserAdminByGroup makes an account an administrator because a group
	// gives its members Administrator (model.User.AdminByGroup). Every
	// UpdateUserRole clears the mark.
	SetUserAdminByGroup(ctx context.Context, userID int64) error
	ListUsersByProvider(ctx context.Context, providerID int64) ([]*model.User, error)
	ListUsers(ctx context.Context) ([]*model.User, error)
	CountUsers(ctx context.Context) (int64, error)
	UpdateUserPassword(ctx context.Context, id int64, hash string) error
	UpdateUserEmail(ctx context.Context, id int64, email string) error
	UpdateUserDisplayName(ctx context.Context, id int64, displayName string) error
	// UpdateUserAvatar sets (or clears, with "") the profile picture — a small
	// data: URI or an http(s)/site-relative URL. Validation lives at the API
	// boundary; the store only persists what it is handed.
	UpdateUserAvatar(ctx context.Context, id int64, avatarURL string) error
	UpdateUserLocale(ctx context.Context, id int64, locale, tz string) error
	UpdateUserRole(ctx context.Context, id int64, role string) error
	TouchLastLogin(ctx context.Context, id int64) error
	// DeleteUser removes the account AND, in the same transaction, the public
	// links it opened (`shares.created_by` = the account): download links and
	// file requests (the maintainer, 2026-09-28: "Kapansın"). Every door that deletes
	// an account — an administrator, a tenant deletion, a rolled-back
	// provisioning — goes through here, so none of them can leave a working
	// link behind. It used to keep the rows and clear `created_by` (ON DELETE
	// SET NULL), and a link with no creator is a link nobody answers for.
	//
	// ⚠ An app's own public page (a signer's page — plugin_id > 0 with a
	// page_id, model.Share.IsApp) stays, as it stays when its requester loses
	// sharing (handlers.linkCreatorAllows): the app opened it. Links made
	// before shares recorded a creator have none to match and are untouched.
	DeleteUser(ctx context.Context, id int64) error
	// DeleteUserWithLinks is DeleteUser that also says how many of the
	// account's links were still open (not expired, not revoked) when they
	// went — the number the audit row records. Removed rows of links that had
	// already ended are not counted.
	DeleteUserWithLinks(ctx context.Context, id int64) (closedLinks int64, err error)

	// TOTP / 2FA
	SetTotpPendingSecret(ctx context.Context, id int64, secret string, recoveryCodes []string) error
	ActivateTotp(ctx context.Context, id int64) error
	ClearTotp(ctx context.Context, id int64) error

	// Sessions
	CreateSession(ctx context.Context, userID int64, token string, expiresAt time.Time, ip, ua string) (*model.Session, error)
	GetSessionByToken(ctx context.Context, token string) (*model.Session, error)
	DeleteSession(ctx context.Context, token string) error
	// SetSessionIDToken / GetSessionIDToken keep the IdP's id_token beside an
	// OIDC session, so sign-out can end the IdP's session too (id_token_hint).
	// Get returns "" — never an error — for a session that has none or does
	// not exist: sign-out falls back to local-only and must not fail on it.
	SetSessionIDToken(ctx context.Context, token, idToken string) error
	GetSessionIDToken(ctx context.Context, token string) (string, error)
	DeleteSessionsForUser(ctx context.Context, userID int64, exceptToken string) error
	CountActiveSessions(ctx context.Context) (int64, error)
	DeleteExpiredSessions(ctx context.Context) error

	// API tokens — long-lived bearer credentials for AI / MCP / FilexClient.
	CreateAPIToken(ctx context.Context, t *model.APIToken) (*model.APIToken, error)
	GetAPITokenByHash(ctx context.Context, tokenHash string) (*model.APIToken, error)
	GetAPITokenByID(ctx context.Context, id int64) (*model.APIToken, error)
	ListAPITokens(ctx context.Context) ([]*model.APIToken, error)
	ListAPITokensByUser(ctx context.Context, userID int64) ([]*model.APIToken, error)
	TouchAPIToken(ctx context.Context, id int64) error
	// UpdateAPITokenMeta edits a token's display metadata (label / username
	// allow-list) and its kind ("user" / "app", migration 00030); nil leaves a
	// field unchanged. The credential itself is immutable.
	//
	// Kind is editable because migration 00030 defaults every pre-existing row
	// to "app": a personal token minted before the split needs one admin edit
	// to become a "user" token again.
	UpdateAPITokenMeta(ctx context.Context, id int64, label, usernames, kind *string) error
	// UpdateAPITokenScopes writes a token's whole list. Its only caller
	// changes the levels of the permissions of package tokenperm
	// (`comments:rw`, PATCH .../tokens/{id} {permissions}); the verbs and the
	// `root:` are written back as they were, so they stay immutable.
	UpdateAPITokenScopes(ctx context.Context, id int64, scopes string) error
	DeleteAPIToken(ctx context.Context, id int64) error

	// S3 access keys (migration 00026) — the credential an S3 client signs
	// with. Unlike an API token these hold a RECOVERABLE secret, because SigV4
	// verifies by recomputing an HMAC chain rather than by comparing a hash;
	// internal/secretbox is what keeps that out of a database dump. A key
	// minted from a token INHERITS that token's permissions and never widens
	// them (see model.S3AccessKey).
	CreateS3AccessKey(ctx context.Context, k *model.S3AccessKey) (*model.S3AccessKey, error)
	GetS3AccessKey(ctx context.Context, accessKeyID string) (*model.S3AccessKey, error)
	GetS3AccessKeyByID(ctx context.Context, id int64) (*model.S3AccessKey, error)
	ListS3AccessKeys(ctx context.Context, userID int64) ([]*model.S3AccessKey, error)
	TouchS3AccessKey(ctx context.Context, id int64) error
	SetS3AccessKeyDisabled(ctx context.Context, id int64, disabled bool) error
	DeleteS3AccessKey(ctx context.Context, id, userID int64) error

	// SSH public keys (migration 00027) — how an account reaches the SFTP
	// endpoint without sending a password. The signature is verified by
	// x/crypto/ssh before any of these are called; what the lookup decides is
	// which ACCOUNT the key belongs to, which is why GetSSHPublicKey takes a
	// fingerprint and returns exactly one row.
	CreateSSHPublicKey(ctx context.Context, k *model.SSHPublicKey) (*model.SSHPublicKey, error)
	GetSSHPublicKey(ctx context.Context, fingerprint string) (*model.SSHPublicKey, error)
	GetSSHPublicKeyByID(ctx context.Context, id int64) (*model.SSHPublicKey, error)
	ListSSHPublicKeys(ctx context.Context, userID int64) ([]*model.SSHPublicKey, error)
	TouchSSHPublicKey(ctx context.Context, id int64) error
	SetSSHPublicKeyDisabled(ctx context.Context, id int64, disabled bool) error
	DeleteSSHPublicKey(ctx context.Context, id, userID int64) error

	// NFS exports (migration 00028) — an NFSv3 mount bound to one account by a
	// high-entropy export PATH, because NFSv3 has no authentication filex can
	// use. GetNFSExport takes the sha256 of that path: the server only ever
	// compares, so the plaintext is never stored.
	CreateNFSExport(ctx context.Context, e *model.NFSExport) (*model.NFSExport, error)
	GetNFSExport(ctx context.Context, tokenHash string) (*model.NFSExport, error)
	GetNFSExportByID(ctx context.Context, id int64) (*model.NFSExport, error)
	ListNFSExports(ctx context.Context, userID int64) ([]*model.NFSExport, error)
	TouchNFSExport(ctx context.Context, id int64) error
	SetNFSExportDisabled(ctx context.Context, id int64, disabled bool) error
	DeleteNFSExport(ctx context.Context, id, userID int64) error

	// Storage plugins (migration 00029) — the admin's registration of an
	// out-of-process storage driver; see internal/plugin. Runtime state is
	// NOT here (the manager re-derives it by starting the plugin).
	CreatePlugin(ctx context.Context, p *model.Plugin) (*model.Plugin, error)
	GetPlugin(ctx context.Context, id int64) (*model.Plugin, error)
	GetPluginByName(ctx context.Context, name string) (*model.Plugin, error)
	ListPlugins(ctx context.Context) ([]*model.Plugin, error)
	UpdatePlugin(ctx context.Context, p *model.Plugin) error
	DeletePlugin(ctx context.Context, id int64) error

	// App plugins (migration 00042) — in-process WebAssembly plugins; see
	// internal/wasmplugin. The row is the admin's intent + the approved grant;
	// runtime state is derived by loading the module.
	CreateAppPlugin(ctx context.Context, p *model.AppPlugin) (*model.AppPlugin, error)
	GetAppPlugin(ctx context.Context, id int64) (*model.AppPlugin, error)
	GetAppPluginByName(ctx context.Context, name string) (*model.AppPlugin, error)
	ListAppPlugins(ctx context.Context) ([]*model.AppPlugin, error)
	UpdateAppPlugin(ctx context.Context, p *model.AppPlugin) error
	// DeleteAppPlugin removes the row and everything keyed on it (settings,
	// overrides, state, jobs).
	DeleteAppPlugin(ctx context.Context, id int64) error
	// App versions an upgrade replaced (migration 00066), newest first.
	CreateAppPluginVersion(ctx context.Context, v *model.AppPluginVersion) (*model.AppPluginVersion, error)
	ListAppPluginVersions(ctx context.Context, pluginID int64) ([]*model.AppPluginVersion, error)
	DeleteAppPluginVersion(ctx context.Context, id int64) error
	GetAppPluginSettings(ctx context.Context, pluginID int64) (map[string]string, error)
	// PutAppPluginSettings replaces the whole set.
	PutAppPluginSettings(ctx context.Context, pluginID int64, values map[string]string) error
	ListAppPluginOverrides(ctx context.Context, pluginID int64) ([]*model.AppPluginOverride, error)
	// PutAppPluginOverrides replaces the whole set.
	PutAppPluginOverrides(ctx context.Context, pluginID int64, rows []*model.AppPluginOverride) error
	GetAppPluginState(ctx context.Context, pluginID, storageID int64, pathHash, key string) (string, bool, error)
	// SetAppPluginState records one key. rel is the file's storage-relative
	// path, stored beside the hash so a listing can name the file.
	SetAppPluginState(ctx context.Context, pluginID, storageID int64, pathHash, rel, key, value string) error
	DeleteAppPluginState(ctx context.Context, pluginID, storageID int64, pathHash, key string) error
	CreateAppPluginJob(ctx context.Context, j *model.AppPluginJob) error
	GetAppPluginJob(ctx context.Context, id string) (*model.AppPluginJob, error)
	// ListAppPluginJobsByOp returns the jobs behind the given ops rows, keyed
	// by op id — what the ops tray uses to decorate plugin rows.
	ListAppPluginJobsByOp(ctx context.Context, opIDs []int64) (map[int64]*model.AppPluginJob, error)
	UpdateAppPluginJob(ctx context.Context, j *model.AppPluginJob) error
	// SetAppPluginJobOp writes ONLY the ops-row id onto a job.
	//
	// The whole-row update cannot be used for this: submitting the job wakes
	// the worker immediately, a short action can finish before the handler
	// gets its answer back, and writing the handler's copy of the row then
	// erases the status, the message and the outputs the worker just wrote.
	// The symptom is silent and awful — the ops row says "ok" and the
	// message the plugin produced (a public link and its PIN, say) is gone.
	SetAppPluginJobOp(ctx context.Context, jobID string, opID int64) error
	CreateAppPluginSigningKey(ctx context.Context, k *model.AppPluginSigningKey) error
	GetAppPluginSigningKey(ctx context.Context, id string) (*model.AppPluginSigningKey, error)
	// GetAppPluginSigningCA returns the tenant's live CA row, or nil.
	GetAppPluginSigningCA(ctx context.Context, tenantID int64) (*model.AppPluginSigningKey, error)
	// GetAppPluginSealKey returns the live platform seal of one app for one
	// tenant (purpose "platform", not destroyed), or nil.
	GetAppPluginSealKey(ctx context.Context, tenantID, pluginID int64) (*model.AppPluginSigningKey, error)
	RetireAppPluginSigningCA(ctx context.Context, tenantID int64) error
	// ListAppPluginSigningCAs returns every CA a tenant has ever signed
	// with, retired ones included, newest first. Nothing deletes these: a
	// signature made years ago is only verifiable while the certificate
	// that issued it is still here.
	ListAppPluginSigningCAs(ctx context.Context, tenantID int64) ([]*model.AppPluginSigningKey, error)
	// DestroyAppPluginSigningKey empties the sealed key and stamps destroyed_at.
	DestroyAppPluginSigningKey(ctx context.Context, id string) error
	// ListAppPluginStateFiles returns the files this plugin keeps the given
	// state key on — the key joined back to the node rows, so a file that
	// was deleted is simply not in the answer. An empty key lists every key.
	// Rows come in one fixed order (path, storage, key) and offset skips that
	// many of them, so a caller that narrows the rows further (state_list:
	// the person's ACL, a token's root) can read on until it has enough.
	ListAppPluginStateFiles(ctx context.Context, pluginID int64, key string, limit, offset int) ([]*model.AppPluginStateFile, error)
	// ListAppPluginStateKeys returns, per path hash, the "<plugin name>:<key>"
	// pairs kept on those files — what a listing shows so the menu can offer
	// state-aware actions (applies.state). Values are never returned.
	ListAppPluginStateKeys(ctx context.Context, storageID int64, pathHashes []string) (map[string][]string, error)
	// PutAppPluginLock inserts or replaces the lock on (storage, path).
	PutAppPluginLock(ctx context.Context, l *model.AppPluginLock) error
	GetAppPluginLock(ctx context.Context, storageID int64, pathHash string) (*model.AppPluginLock, error)
	// ListAppPluginLocks returns every lock on a storage (0 = all storages),
	// expired ones included; callers filter with Live.
	ListAppPluginLocks(ctx context.Context, storageID int64) ([]*model.AppPluginLock, error)
	DeleteAppPluginLock(ctx context.Context, storageID int64, pathHash string) error
	// DeleteExpiredAppPluginLocks drops locks whose until passed before now.
	DeleteExpiredAppPluginLocks(ctx context.Context, now time.Time) (int64, error)

	// App-plugin schedule (migration 00050) — the hourly wake-up of an app
	// that holds the `schedule` permission, and the work that wake-up asked
	// for. One row per (plugin, key); the empty key is the wake-up itself.
	//
	// PutAppPluginScheduleItem inserts the item or moves the existing one.
	// A row another process is RUNNING is left alone and no error is
	// returned: the running process owns it and will finish it.
	PutAppPluginScheduleItem(ctx context.Context, it *model.AppPluginScheduleItem) error
	// ClaimAppPluginScheduleItem takes the lease on a due row: one
	// conditional UPDATE from due to running, true only for the process
	// whose UPDATE matched. This is what stops two filex processes on one
	// database from running the same item twice.
	ClaimAppPluginScheduleItem(ctx context.Context, pluginID int64, key, owner string, now time.Time) (bool, error)
	// FinishAppPluginScheduleItem ends a claimed row. A non-nil rearmAt puts
	// it back to `due` at that time instead (the wake-up re-arming itself
	// for the next hour); status is then ignored and errMsg becomes the
	// note the row carries until the next wake-up.
	FinishAppPluginScheduleItem(ctx context.Context, pluginID int64, key, status, jobID, errMsg string, rearmAt *time.Time) error
	// DueAppPluginScheduleItems returns rows that are `due` at or before
	// now, oldest first, capped at limit.
	DueAppPluginScheduleItems(ctx context.Context, now time.Time, limit int) ([]*model.AppPluginScheduleItem, error)
	// NextAppPluginScheduleDue is when the earliest `due` row comes due, so
	// the scheduler can sleep exactly that long instead of polling.
	NextAppPluginScheduleDue(ctx context.Context) (*time.Time, error)
	// ListAppPluginScheduleItems returns every row of one app, soonest first.
	ListAppPluginScheduleItems(ctx context.Context, pluginID int64) ([]*model.AppPluginScheduleItem, error)
	// ReapAppPluginScheduleItems drops finished rows older than before.
	ReapAppPluginScheduleItems(ctx context.Context, before time.Time) (int64, error)

	// File grants — per-user/per-folder ACL (RBAC feature, migration 00012).
	ListFileGrantsByStorageUser(ctx context.Context, storageID, userID int64) ([]*model.FileGrant, error)
	ListFileGrantsByStorage(ctx context.Context, storageID int64) ([]*model.FileGrant, error)
	ListAllFileGrants(ctx context.Context) ([]*model.FileGrant, error)
	GetFileGrant(ctx context.Context, id int64) (*model.FileGrant, error)
	CreateFileGrant(ctx context.Context, g *model.FileGrant) (*model.FileGrant, error)
	UpdateFileGrantLevel(ctx context.Context, id int64, level string) error
	DeleteFileGrant(ctx context.Context, id int64) error

	// Per-user permissions (migration 00069, see internal/perm). Overrides
	// are keyed by permission name; values are model.PermAllow/PermDeny. The
	// store persists what it is handed — perm.ValidateEffects runs first.
	// GetUserPermissionOverrides returns an empty map (never an error) for a
	// user with no row. SetUserPermissionOverrides with an empty map removes
	// the row. ListUserPermissionOverrides returns every user that has one.
	GetUserPermissionOverrides(ctx context.Context, userID int64) (map[string]string, error)
	SetUserPermissionOverrides(ctx context.Context, userID int64, overrides map[string]string, updatedBy *int64) error
	ListUserPermissionOverrides(ctx context.Context) (map[int64]map[string]string, error)

	// Permission rules (migration 00069). ListPermissionRules is id order —
	// the order perm.Resolve reports "the first rule that denied it" in.
	// GetPermissionRule returns sql.ErrNoRows for an unknown id.
	ListPermissionRules(ctx context.Context) ([]*model.PermissionRule, error)
	GetPermissionRule(ctx context.Context, id int64) (*model.PermissionRule, error)
	CreatePermissionRule(ctx context.Context, r *model.PermissionRule) (*model.PermissionRule, error)
	UpdatePermissionRule(ctx context.Context, r *model.PermissionRule) error
	DeletePermissionRule(ctx context.Context, id int64) error

	// SSO groups from a user's most recent SSO login (migration 00069).
	// SetUserSSOGroups replaces the whole set.
	ListUserSSOGroups(ctx context.Context, userID int64) ([]string, error)
	SetUserSSOGroups(ctx context.Context, userID int64, groups []string) error

	// A person's one custom role (migration 00069). GetUserCustomRole is 0
	// for none; SetUserCustomRole with 0 takes it away. Deleting the role
	// takes it from everyone who held it (ON DELETE CASCADE).
	GetUserCustomRole(ctx context.Context, userID int64) (int64, error)
	SetUserCustomRole(ctx context.Context, userID, roleID int64) error
	ListUserCustomRoles(ctx context.Context) (map[int64]int64, error)

	// Groups (migration 00074, internal/group). One implementation for every
	// engine: db.GroupSQL, embedded in each driver's Store. GetGroup and
	// GetGroupFileGrant return sql.ErrNoRows for an unknown id. Deleting a
	// group takes its memberships and grants along; deleting a role leaves
	// its groups with none (ReassignGroupRole first to move them).
	ListGroups(ctx context.Context) ([]*model.Group, error)
	GetGroup(ctx context.Context, id int64) (*model.Group, error)
	CreateGroup(ctx context.Context, g *model.Group) (*model.Group, error)
	UpdateGroup(ctx context.Context, g *model.Group) error
	DeleteGroup(ctx context.Context, id int64) error
	ReassignGroupRole(ctx context.Context, from, to int64) error
	ListGroupMembers(ctx context.Context, groupID int64) ([]*model.GroupMember, error)
	ListUserGroupMemberships(ctx context.Context, userID int64) ([]*model.GroupMember, error)
	ListAllGroupMembers(ctx context.Context) ([]*model.GroupMember, error)
	AddGroupMember(ctx context.Context, groupID, userID int64) error
	RemoveGroupMember(ctx context.Context, groupID, userID int64) error
	SetUserLinkedGroups(ctx context.Context, userID int64, source string, groupIDs []int64) (added, removed []int64, err error)
	DropForeignMemberships(ctx context.Context, userID, providerID int64) error

	// The built-in level an account had before a group's role moved it
	// (user_group_levels), so leaving the group can put it back.
	GetUserGroupLevel(ctx context.Context, userID int64) (string, bool, error)
	SetUserGroupLevel(ctx context.Context, userID int64, level string) error
	DeleteUserGroupLevel(ctx context.Context, userID int64) error
	// The groups of a person's latest LDAP sign-in (migration 00084), for
	// re-applying a changed LDAP link without waiting for them to sign in.
	ListUserLDAPGroups(ctx context.Context, userID int64) ([]string, error)
	// Where a group directory sync brought in comes from (migration 00084).
	SetGroupDirectory(ctx context.Context, id int64, directoryID, name, state string) error
	SetUserLDAPGroups(ctx context.Context, userID int64, groups []string) error

	// A group's folder grants (group_file_grants). Rows come back as
	// model.FileGrant with GroupID set and UserID zero.
	ListGroupFileGrantsByStorage(ctx context.Context, storageID int64) ([]*model.FileGrant, error)
	ListGroupFileGrantsByStorageUser(ctx context.Context, storageID, userID int64) ([]*model.FileGrant, error)
	ListGroupFileGrantsByGroup(ctx context.Context, groupID int64) ([]*model.FileGrant, error)
	ListAllGroupFileGrants(ctx context.Context) ([]*model.FileGrant, error)
	GetGroupFileGrant(ctx context.Context, id int64) (*model.FileGrant, error)
	CreateGroupFileGrant(ctx context.Context, g *model.FileGrant) (*model.FileGrant, error)
	UpdateGroupFileGrantLevel(ctx context.Context, id int64, level string) error
	DeleteGroupFileGrant(ctx context.Context, id int64) error

	// Shares
	CreateShare(ctx context.Context, share *model.Share) (*model.Share, error)
	GetShareByID(ctx context.Context, id int64) (*model.Share, error)
	GetShareByToken(ctx context.Context, token string) (*model.Share, error)
	ListSharesByNode(ctx context.Context, nodeID int64) ([]*model.Share, error)
	ListAllShares(ctx context.Context, creatorID *int64, activeOnly bool, limit, offset int) ([]*ShareWithMeta, int64, error)
	RevokeShare(ctx context.Context, id int64) error
	IncrementShareDownload(ctx context.Context, id int64) error
	// IncrementShareVisit counts one opening of an app page (00052) — the
	// counter an app page's visit ceiling is measured against. Page views
	// are not downloads (the owner's ruling, 2026-09-21).
	IncrementShareVisit(ctx context.Context, id int64) error
	// ReserveShareDownload claims ONE download against the link's cap and
	// reports whether it got one. This is the cap's only real enforcement
	// point: a check that reads the counter and a serve that bumps it
	// afterwards are two separate steps, and every request that starts inside
	// that gap passes the check (measured on fm.example.com: a 1-download link
	// handed three full files to three overlapping clients). Claim, then serve.
	ReserveShareDownload(ctx context.Context, id int64) (bool, error)
	// ReleaseShareDownload hands a reserved slot back. Used only when the serve
	// fails before a single byte reaches the client, so a storage error does
	// not silently eat one of the downloads the owner granted.
	ReleaseShareDownload(ctx context.Context, id int64) error
	IncrementShareUpload(ctx context.Context, id int64, n int) error
	DeleteShare(ctx context.Context, id int64) error
	DeleteExpiredShares(ctx context.Context) error

	// UpdateShareAppState replaces the plugin's durable record for one link
	// (the share_state host function). Bounded by the caller at 64 KiB.
	UpdateShareAppState(ctx context.Context, id int64, stateJSON string) error
	// UpdateSharePinLock writes the PIN strike counter and the lock deadline.
	// Separate from every other update so a failed guess cannot rewrite the
	// link's expiry, caps or exposed files by riding along in a whole-row save.
	UpdateSharePinLock(ctx context.Context, id int64, fails int, until *time.Time) error
	// ListAppPluginShares returns the links an installed app opened, newest
	// first. pluginID 0 means every app (never the ordinary shares);
	// activeOnly drops the expired and revoked ones.
	ListAppPluginShares(ctx context.Context, pluginID int64, activeOnly bool, limit, offset int) ([]*ShareWithMeta, int64, error)

	// Chunked uploads
	CreateChunkedUpload(ctx context.Context, u *model.ChunkedUpload) error
	GetChunkedUpload(ctx context.Context, id string) (*model.ChunkedUpload, error)
	UpdateChunkedUploadParts(ctx context.Context, id string, parts []model.UploadPart) error
	DeleteChunkedUpload(ctx context.Context, id string) error
	DeleteExpiredChunkedUploads(ctx context.Context) error

	// Staged uploads — the driver-agnostic resumable path (docs/UPLOADS.md).
	// A separate table from chunked_uploads on purpose: see the comment in
	// db/migrations/sqlite/00024_staged_uploads.sql.
	CreateStagedUpload(ctx context.Context, u *model.StagedUpload) error
	GetStagedUpload(ctx context.Context, id string) (*model.StagedUpload, error)
	// GetStagedUploadByNode is the reverse index chunk 5 (read-during-transfer)
	// uses to find the staging directory holding a node's bytes.
	GetStagedUploadByNode(ctx context.Context, nodeID int64) (*model.StagedUpload, error)
	UpdateStagedUploadProgress(ctx context.Context, id string, receivedBytes int64) error
	UpdateStagedUploadState(ctx context.Context, id, state, errMsg string) error
	AttachStagedUploadTarget(ctx context.Context, id string, nodeID, opID int64) error
	DeleteStagedUpload(ctx context.Context, id string) error
	ListStagedUploads(ctx context.Context, state string, limit int) ([]*model.StagedUpload, error)
	// ListIdleStagedUploads returns rows whose last activity is older than
	// `before` — the staging sweeper's input.
	ListIdleStagedUploads(ctx context.Context, before time.Time, limit int) ([]*model.StagedUpload, error)
	// SumOpenStagedUploadBytes is the quota RESERVATION: the declared size of
	// every not-yet-committed upload the user owns. Derived rather than stored,
	// so it can never drift from the rows it describes and a row leaving the
	// open set releases its reservation by construction.
	SumOpenStagedUploadBytes(ctx context.Context, userID int64) (int64, error)

	// SetNodeTransferState sets nodes.transfer_state: "staged" while the bytes
	// are in filex's staging area, "stored" once they are on the driver, and
	// "failed" when the transfer to the driver did not succeed.
	SetNodeTransferState(ctx context.Context, nodeID int64, state string) error
	// ListUnstoredNodes pages through every LIVE row whose transfer_state is
	// "staged" or "failed" — bytes a staged upload committed that were never
	// confirmed on the storage — in id order, after afterID. The staged-upload
	// boot pass settles the ones whose bytes did land.
	ListUnstoredNodes(ctx context.Context, afterID int64, limit int) ([]*model.Node, error)

	// Sync runs / conflicts
	CreateSyncRun(ctx context.Context, storageID int64, cursorBefore string) (*model.SyncRun, error)
	FinishSyncRun(ctx context.Context, id int64, cursorAfter string, seen, added, updated, deleted int, status, errMsg string) error
	GetSyncRun(ctx context.Context, id int64) (*model.SyncRun, error)
	GetLastSyncRun(ctx context.Context, storageID int64) (*model.SyncRun, error)
	// GetLastSyncRunByStatus is the most recent FINISHED run of a storage with
	// the given status (sql.ErrNoRows when there is none) — the tombstone
	// guard's baseline is the last run that finished "ok", never one that
	// was cut short with whatever it had counted by then.
	GetLastSyncRunByStatus(ctx context.Context, storageID int64, status string) (*model.SyncRun, error)
	// AbortUnfinishedSyncRuns closes every run with no finished_at as
	// "aborted", with errMsg as its error, and reports how many it closed.
	// Called once when the sync worker starts, before any run of its own: a
	// row still open then belongs to a process that is gone.
	AbortUnfinishedSyncRuns(ctx context.Context, errMsg string) (int64, error)
	ListSyncRuns(ctx context.Context, storageID int64, limit int) ([]*model.SyncRun, error)
	ListSyncRunsAcrossAll(ctx context.Context, storageID int64, status string, limit, offset int) ([]*model.SyncRun, int64, error)
	CreateSyncConflict(ctx context.Context, c *model.SyncConflict) error
	ListUnresolvedConflicts(ctx context.Context) ([]*model.SyncConflict, error)
	ListConflictsByStorage(ctx context.Context, storageID int64, limit int) ([]*model.SyncConflict, error)
	CountSyncConflictsByRun(ctx context.Context, runID int64) (int64, error)
	ResolveConflict(ctx context.Context, id int64, resolution string) error
	CountQueueDepth(ctx context.Context) (int64, error)

	// Audit
	InsertAuditEntry(ctx context.Context, e *model.AuditEntry) error
	ListAuditRecent(ctx context.Context, limit int) ([]*model.AuditEntry, error)
	ListAuditFiltered(ctx context.Context, userID *int64, action string, from, to *time.Time, limit, offset int) ([]*AuditEntryWithUser, int64, error)

	// Settings
	GetSetting(ctx context.Context, key string) (string, error)
	UpsertSetting(ctx context.Context, key, value string) error
	// DeleteSettingsWithPrefix removes every setting whose key starts with
	// prefix — a removed LDAP directory's rows ("auth.ldap-partner.").
	DeleteSettingsWithPrefix(ctx context.Context, prefix string) error
	ListSettings(ctx context.Context) (map[string]string, error)

	// External services
	UpsertExternalService(ctx context.Context, name string, enabled bool, url, secretEnc, optionsJSON string, lastCheck time.Time, lastState string) error
	GetExternalService(ctx context.Context, name string) (*ExternalService, error)
	ListExternalServices(ctx context.Context) ([]*ExternalService, error)
	UpdateExternalServiceState(ctx context.Context, name string, lastCheck time.Time, state string) error

	// Duplicate report — every live file node whose (size, non-empty etag)
	// pair occurs more than once, minSize filtering applied in SQL. Rows come
	// back ordered (size DESC, etag, id) so the handler can group them
	// contiguously. Powers GET /api/admin/duplicates (v0.2 "Bul").
	ListDuplicateNodes(ctx context.Context, minSize int64) ([]DuplicateNode, error)

	// Cross-storage analytics for the dashboard
	SumNodesBytesByStorage(ctx context.Context, storageID int64) (int64, error)
	CountNodesAddedSince(ctx context.Context, storageID int64, since time.Time) (int64, error)
	CountNodesDeletedSince(ctx context.Context, storageID int64, since time.Time) (int64, error)
	CountTotalShares(ctx context.Context) (int64, error)

	// Thumbnails
	GetThumbnail(ctx context.Context, nodeID int64) (*model.Thumbnail, error)
	// GetThumbnails is GetThumbnail for many nodes in batched queries: what a
	// listing asks once instead of once per file. Nodes without a row are
	// absent from the map.
	GetThumbnails(ctx context.Context, ids []int64) (map[int64]*model.Thumbnail, error)
	// ListThumbnailProblems lists the files whose thumbnail failed or was
	// skipped - and those drawn only after an earlier handler failed (a ready
	// row with more than one attempt, 0.50) - most recently attempted first,
	// at most limit of them. nil storageIDs is every storage; an empty slice
	// is none. Trashed files and end-to-end encrypted skips (nothing to
	// repair there) are left out.
	ListThumbnailProblems(ctx context.Context, storageIDs []int64, limit int) ([]*model.ThumbnailProblem, error)
	// ListPreviewCandidates answers, for each folder in parentIDs, the
	// perFolder live FILES directly in it that came in last: newest first by
	// the later of the row's creation (when the file entered the catalogue)
	// and the file's own modification time, then by name. Subfolders are
	// never looked into, and a folder that holds only folders is absent. What
	// a folder's card is drawn from (docs/thumbnails.md, Folder previews).
	// ONE query for a whole listing's folders.
	ListPreviewCandidates(ctx context.Context, storageID int64, parentIDs []int64, perFolder int) (map[int64][]*model.Node, error)
	UpsertThumbnail(ctx context.Context, t *model.Thumbnail) error
	SetThumbnailState(ctx context.Context, nodeID int64, state, errMsg string) error
	// DeleteThumbnail drops the row for one node. The cached JPEG on disk is
	// the pipeline's business (internal/thumb); this is only the catalogue
	// half, and it exists so a purge does not have to wait for the FK cascade
	// to be the only thing that ever removed it.
	DeleteThumbnail(ctx context.Context, nodeID int64) error
	// ExistingNodeIDs reports which of the given ids still have a `nodes` row —
	// TRASHED ROWS INCLUDED, because a trashed file is restorable and must keep
	// its thumbnail. It is the safety interlock of the thumbnail-cache sweeper:
	// a cached file is deleted only when this positively says its node is gone.
	ExistingNodeIDs(ctx context.Context, ids []int64) (map[int64]bool, error)

	// Node versions
	CreateNodeVersion(ctx context.Context, v *model.NodeVersion) (*model.NodeVersion, error)
	ListNodeVersions(ctx context.Context, nodeID int64) ([]*model.NodeVersion, error)
	GetNodeVersion(ctx context.Context, id int64) (*model.NodeVersion, error)
	NextNodeVersionNumber(ctx context.Context, nodeID int64) (int, error)
	DeleteNodeVersion(ctx context.Context, id int64) error
	DeleteOldNodeVersions(ctx context.Context, nodeID int64, keep int) ([]*model.NodeVersion, error)
	// ListNodeIDsWithVersions returns the distinct node ids that have at
	// least one node_versions row — the work list for the daily version
	// retention job (v0.4 "Koru").
	ListNodeIDsWithVersions(ctx context.Context) ([]int64, error)

	// Sync conflicts (admin views)
	ListSyncConflictsByRun(ctx context.Context, runID int64) ([]*model.SyncConflict, error)
	ListSyncConflictsByStorage(ctx context.Context, storageID int64, limit int) ([]*model.SyncConflict, error)

	// Search rebuild
	AllNodesForIndex(ctx context.Context) ([]*model.Node, error)

	// Quota
	GetUserUsage(ctx context.Context, userID int64) (used, limit int64, err error)
	IncrementUserUsage(ctx context.Context, userID int64, delta int64) error
	SetUserQuota(ctx context.Context, userID int64, bytes int64) error
	// SetUserEnabled flips the account on/off (migration 00022). A disabled
	// user cannot start a session; nothing they own is touched. It clears the
	// server's disabled reason (migration 00079): the administrator decided.
	SetUserEnabled(ctx context.Context, userID int64, enabled bool) error
	RecomputeUserUsage(ctx context.Context, userID int64) (int64, error)

	// Node owner / attribution (migration 00004 + 00038)
	//
	// owner_id is who PUT THE THING HERE; last_actor_id is who touched it
	// last; external_upload says it arrived through an anonymous drop link.
	// NULL means SYSTEM in both id columns — see model.Node.
	//
	// A CREATE does not use these: internal/quotastore stamps the model and
	// the INSERT carries the values, so a bulk write costs no extra queries.
	// They exist for the mutations that change attribution on a row that
	// already exists (overwrite, move, restore).
	SetNodeOwner(ctx context.Context, nodeID int64, ownerID *int64) error
	GetNodeOwner(ctx context.Context, nodeID int64) (*int64, error)
	SetNodeActor(ctx context.Context, nodeID int64, actorID *int64) error
	SetNodeExternalUpload(ctx context.Context, nodeID int64, external bool) error
	// SetNodeDeletedBy names who put a trashed row in the trash: the row and
	// every trashed row under its path that names nobody yet (a folder's
	// contents, trashed with it). A live row is left alone. Every soft delete
	// clears the name first; internal/quotastore writes it after the delete,
	// from the acting identity.
	SetNodeDeletedBy(ctx context.Context, nodeID int64, by *int64) error
	// GetUserDisplayNames resolves a batch of user ids to the label an Owner
	// column shows. One query for a whole listing page, names only — never
	// full user rows. See the sqlite driver for why.
	GetUserDisplayNames(ctx context.Context, ids []int64) (map[int64]string, error)

	// Trash retention
	//
	// ListTrashedExpired returns up to `limit` soft-deleted nodes whose
	// deleted_at is older than `before`, in id order, strictly after `afterID`
	// (0 starts at the first), narrowed to storageIDs: nil means every
	// storage, and an EMPTY, non-nil slice matches nothing (a scope that
	// reaches no storage must not read as "no restriction"). A sweep passes
	// the last id it saw back in, so every row is met once per run whatever
	// the caller did with it.
	//
	// ⚠ The narrowing is in the SQL on purpose. The purge used to read every
	// storage's rows and skip the foreign ones in Go; a skipped row is never
	// removed, so behind a full batch of somebody else's rows it re-read the
	// same batch forever.
	//
	// ⚠ The cursor is the id, never deleted_at. SQLite keeps CURRENT_TIMESTAMP
	// as `YYYY-MM-DD HH:MM:SS` and the driver writes a time.Time parameter in
	// another spelling, so a timestamp cursor compared unequal to the rows that
	// share its second — and a trash emptied in one burst shares very few
	// seconds between tens of thousands of rows.
	ListTrashedExpired(ctx context.Context, before time.Time, storageIDs []int64, afterID int64, limit int) ([]*model.Node, error)
	// CountTrashedExpired tallies, per storage, the rows ListTrashedExpired
	// walks for the same `before`: how many, and the bytes their files hold
	// (a folder's size is a cached total of its files, so it is not added).
	CountTrashedExpired(ctx context.Context, before time.Time) (map[int64]TrashTally, error)
	// ListTrashed returns soft-deleted nodes (paginated). storage filter optional.
	ListTrashed(ctx context.Context, storageID *int64, limit, offset int) ([]*model.Node, int, error)
	RestoreNode(ctx context.Context, id int64) error
	// RestoreNodeAt restores a soft-deleted node, simultaneously reverting its
	// path/path_hash to the supplied original-path values and re-attaching it
	// to the resolved parent_id (nil = root). Used by trash.Service.Restore
	// to undo the `.filex-trash/` rename.
	RestoreNodeAt(ctx context.Context, id int64, parentID *int64, origPath string) error
	// LookupParentByPath returns the parent_id (nil at root) for a path's
	// parent dir, or an error if the parent dir doesn't exist in the cache.
	LookupParentByPath(ctx context.Context, storageID int64, fullPath string) (*int64, error)

	// Per-user metadata (starred, last_opened). Tags have their own tables
	// since 00055 — see the Tags block below.
	SetUserNodeMeta(ctx context.Context, userID, nodeID int64, key, value string) error
	DeleteUserNodeMeta(ctx context.Context, userID, nodeID int64, key string) error
	GetUserNodeMeta(ctx context.Context, userID, nodeID int64, key string) (string, error)
	ListUserNodeMetaForNode(ctx context.Context, userID, nodeID int64, prefix string) (map[string]string, error)
	ListNodesByUserMeta(ctx context.Context, userID int64, key string, limit int) ([]*model.Node, error)
	// UserNodeMetaPage is one page of the live nodes the person flagged with
	// key, in the given order, with each one's time and the total (filex
	// 0.54: Recent and Starred page, sort and say when). See UserMetaPageSQL.
	UserNodeMetaPage(ctx context.Context, userID int64, key string, order listorder.Order, limit, offset int) ([]UserMetaEntry, int, error)
	// UserNodeMetaAt returns when the person flagged each of ids with key;
	// unflagged ids are absent (a listing's `starred`, one query per page).
	UserNodeMetaAt(ctx context.Context, userID int64, key string, ids []int64) (map[int64]time.Time, error)

	// Per-user VIEW preferences (00039): how this person left each folder, as
	// one JSON document read whole and written whole. Not per-node and not
	// queried by folder, so it is deliberately not user_node_meta — see
	// db/migrations/sqlite/00039_user_view_prefs.sql. "" = nothing stored yet,
	// which is not an error.
	GetUserViewPrefs(ctx context.Context, userID int64) (string, error)
	SetUserViewPrefs(ctx context.Context, userID int64, doc string) error

	// ── Surface preferences (00047) ──
	//
	// What a person chose about the interface itself — theme, palette,
	// density, language — one JSON document per person PER SURFACE ("web" or
	// "desktop"). Distinct from the view prefs above, which are how each
	// FOLDER was left. "" and "no row" are the same answer and neither is an
	// error: nothing chosen yet is every account's first day.
	GetUserPrefs(ctx context.Context, userID int64, surface string) (string, error)
	SetUserPrefs(ctx context.Context, userID int64, surface, doc string) error
	// ListUserPrefs is every document one person stored, each with when it
	// was last written (0.50: an account's "open with" choices are adopted
	// once from the most recently written surface document).
	ListUserPrefs(ctx context.Context, userID int64) ([]UserPrefsDoc, error)
	// Operator-defined themes (00051): a name plus two `--fe-*` token maps,
	// served to every browser beside the built-in palettes. Instance-wide —
	// the table has no tenant column, which is exactly why the admin routes
	// that write them are behind requireSupertenant.
	//
	// ⚠ ListCustomThemes is on the PUBLIC appearance path (an anonymous
	// visitor's share page resolves the instance default through it), so a row
	// whose token JSON cannot be parsed is DROPPED from the list rather than
	// failing the call: one bad row must not take the login page down with it.
	ListCustomThemes(ctx context.Context) ([]*model.CustomTheme, error)
	GetCustomTheme(ctx context.Context, key string) (*model.CustomTheme, error)
	UpsertCustomTheme(ctx context.Context, t *model.CustomTheme) error
	// DeleteCustomTheme reports whether a row was actually removed, so the
	// handler can answer 404 instead of a cheerful 200 for a key that was
	// never there.
	DeleteCustomTheme(ctx context.Context, key string) (bool, error)

	// ── Tags (00055): personal and team vocabularies + the files they are on ──
	//
	// ⚠ The store does not decide who may SEE a tag. Visibility needs the
	// caller, the tenant scope, the token's `root:` confinement and the ACL,
	// none of which a store knows — handlers/tags.go owns it, and every read
	// below returns rows for that code to judge. What the store does own is
	// the vocabulary boundary: TagQuery (rendered once, by TagQueryWhere) says
	// WHOSE tags a query may even consider.
	//
	// ListTags returns the vocabulary rows a query selects, oldest first.
	ListTags(ctx context.Context, q model.TagQuery) ([]*model.Tag, error)
	// CreateTag inserts a vocabulary row. A UNIQUE violation (a concurrent
	// create of the same name) is returned as an error; the caller re-reads.
	CreateTag(ctx context.Context, t *model.Tag) (*model.Tag, error)
	// ListNodeTags returns EVERY tag on a node, of every owner and tenant —
	// the caller filters to what the person may see.
	ListNodeTags(ctx context.Context, nodeID int64) ([]*model.Tag, error)
	// LinkNodeTags adds and removes tag links on one node in one transaction,
	// and deletes a removed tag that no file carries any more (a tag exists
	// while it is on something, as it always has).
	LinkNodeTags(ctx context.Context, nodeID int64, add, remove []int64) error
	// ListNodesByTagIDs returns live nodes carrying ANY of the tags,
	// newest-first (by node updated_at), capped at limit.
	ListNodesByTagIDs(ctx context.Context, tagIDs []int64, limit int) ([]*model.Node, error)
	// ListTagPlacements returns every (tag, live file) pair for the tags a
	// query selects — what the tag listings judge visibility on, file by file.
	ListTagPlacements(ctx context.Context, q model.TagQuery) ([]model.TagPlacement, error)

	// Notifications (in-app bell + webhook delivery audit)
	InsertNotification(ctx context.Context, n *model.NotificationInput) (int64, error)
	GetNotification(ctx context.Context, id int64) (*model.Notification, error)
	// ListNotifications and UnreadNotificationCount take mutedEvents: the
	// caller's already-resolved per-user mute list (see
	// model.NotificationSettings.MutedList). It is applied IN SQL rather than
	// by filtering the returned page, because a page filtered afterwards comes
	// back short and its total still counts the rows the caller then hides.
	// Nil/empty means "no mute filter", which is what the admin/global view
	// (userID == nil) always passes.
	//
	// hiddenBodies are LIKE patterns a row's `body` must NOT match — the
	// notify service's read-time filter for rows recorded about filex's own
	// directories before those stopped being announced (notify.hiddenBodies).
	// SQL for the same reason as the mute list: the unread badge is a COUNT,
	// and a row the list hides but the count still counts is a badge that says
	// "3" over an empty bell. Nil/empty filters nothing. The rows are not
	// changed or deleted — the filter is on the read, never on the history.
	//
	// broadcasts decides how the read treats rows with no user: which of them
	// a bell takes at all (see notify.Bell), and whose read state they carry.
	// It is in SQL for the same reason as the mute list.
	ListNotifications(ctx context.Context, userID *int64, onlyUnread bool, mutedEvents, hiddenBodies []string, broadcasts model.BroadcastFilter, limit, offset int) ([]*model.Notification, int64, error)
	// MarkNotificationRead and MarkAllNotificationsRead stamp rows ADDRESSED
	// to userID and nothing else: a broadcast is many readers' row, and is
	// marked per reader through MarkBroadcastsRead. nil userID stamps the
	// rows' own column regardless of owner (an instance-wide sweep).
	MarkNotificationRead(ctx context.Context, id int64, userID *int64) error
	MarkAllNotificationsRead(ctx context.Context, userID *int64) error
	// MarkBroadcastsRead records that readerID has read these broadcasts
	// (migration 00056). Ids that are not broadcasts are ignored, and marking
	// one twice is not an error. Whether the reader may see them is the
	// caller's question — the store cannot answer it.
	MarkBroadcastsRead(ctx context.Context, readerID int64, ids []int64) error
	// MarkAllBroadcastsRead moves readerID's "mark all read" point to the
	// newest notification: every broadcast up to it is read for that reader,
	// and nobody else. One write however many broadcasts there are.
	MarkAllBroadcastsRead(ctx context.Context, readerID int64) error
	UnreadNotificationCount(ctx context.Context, userID *int64, mutedEvents, hiddenBodies []string, broadcasts model.BroadcastFilter) (int64, error)
	UpdateWebhookStatus(ctx context.Context, id int64, status, errMsg string) error
	GetNotificationSettings(ctx context.Context, userID int64) (*model.NotificationSettings, error)
	// UpsertNotificationSettings writes a person's preferences. A nil
	// UrgentOverridesRaw keeps the urgent choices already stored (migration
	// 00087), so a client that does not know them cannot wipe them.
	UpsertNotificationSettings(ctx context.Context, s *model.NotificationSettings) error

	// The notification digest (migration 00087, internal/notify digest.go):
	// a person's digest point and open window, and the administrators'
	// defaults. Written once for every engine (digest_sql.go).
	//
	// DigestState answers through (every notification at or below it has
	// been told), due (when the open window ends, nil for none) and found.
	DigestState(ctx context.Context, userID int64) (through int64, due *time.Time, found bool, err error)
	// EnsureDigestState gives userID a digest point when they have none — the
	// newest notification there is, so nothing that came before is ever
	// held — and returns their point either way.
	EnsureDigestState(ctx context.Context, userID int64) (int64, error)
	// SetDigestDue opens userID's window to end at due, or brings an open one
	// forward to it; a window that ends earlier is kept. exact sets it as
	// given (a window found to end later than the one recorded).
	SetDigestDue(ctx context.Context, userID int64, due time.Time, exact bool) error
	// ClearDigestDue closes userID's window when it ended at or before now.
	ClearDigestDue(ctx context.Context, userID int64, now time.Time) error
	// DueDigests lists the people whose window ended at or before now, the
	// longest-waiting first, at most limit of them.
	DueDigests(ctx context.Context, now time.Time, limit int) ([]int64, error)
	// AdvanceDigest moves userID's digest point from `from` to `to` and closes
	// their window, and answers whether it did: false when the point is no
	// longer `from` (somebody else told these rows first). Compare-and-set,
	// so a digest is made once however many servers or requests try.
	AdvanceDigest(ctx context.Context, userID, from, to int64, now time.Time) (bool, error)
	// NewestNotificationID is the highest notification id there is, 0 for none.
	NewestNotificationID(ctx context.Context) (int64, error)
	// OldestQuietOwn is when the oldest of userID's OWN rows above `after` of
	// one of these kinds was made, nil for none: when their next window opened.
	OldestQuietOwn(ctx context.Context, userID, after int64, events []string) (*time.Time, error)
	// MarkOwnNotificationsRead stamps the rows with these ids that are
	// addressed to userID and still unread. Others are left alone.
	MarkOwnNotificationsRead(ctx context.Context, userID int64, ids []int64) error
	// GetDigestPolicy is the defaults of one scope (0: the instance), nil when
	// none were saved.
	GetDigestPolicy(ctx context.Context, scope int64) (*model.DigestPolicy, error)
	// SaveDigestPolicy replaces the defaults of p.Scope.
	SaveDigestPolicy(ctx context.Context, p *model.DigestPolicy) error

	// Web Push (task #191, internal/notify push.go): the devices people
	// receive pushes on, and the instance's VAPID key. Written once for every
	// engine (push_sql.go).
	//
	// SavePushSubscription records a device by its endpoint hash: the same
	// person's again keeps its row and mark, another person's is replaced.
	SavePushSubscription(ctx context.Context, s *model.PushSubscription) (*model.PushSubscription, error)
	// ListPushSubscriptions is one person's devices, oldest first.
	ListPushSubscriptions(ctx context.Context, userID int64) ([]*model.PushSubscription, error)
	// ListPushSubscribers is every person with a device.
	ListPushSubscribers(ctx context.Context) ([]int64, error)
	CountPushSubscriptions(ctx context.Context) (int64, error)
	// DeletePushSubscription and DeletePushSubscriptionByHash remove one of
	// userID's own devices; ok is false for one that is not theirs, exactly
	// as for one that does not exist.
	DeletePushSubscription(ctx context.Context, userID, id int64) (bool, error)
	DeletePushSubscriptionByHash(ctx context.Context, userID int64, hash string) (bool, error)
	// DropPushSubscription removes a device by id, whoever's it is (the push
	// service said it is gone).
	DropPushSubscription(ctx context.Context, id int64) error
	// AdvancePushMark moves a device's mark from `from` to `to` (to > from)
	// and answers whether it did - compare-and-set, so a row is pushed once.
	AdvancePushMark(ctx context.Context, id, from, to int64) (bool, error)
	// RecordPushResult notes a push taken or refused; it answers the
	// refusals in a row after the write.
	RecordPushResult(ctx context.Context, id int64, taken bool) (int, error)
	// GetPushVAPIDKey is the instance's key, nil when none was made.
	GetPushVAPIDKey(ctx context.Context) (*model.PushVAPIDKey, error)
	// CreatePushVAPIDKey stores the first key; it fails when there is one.
	CreatePushVAPIDKey(ctx context.Context, k *model.PushVAPIDKey) error
	// ReplacePushVAPIDKey puts k in place of the key and forgets every
	// device, answering how many (run it in WithTx).
	ReplacePushVAPIDKey(ctx context.Context, k *model.PushVAPIDKey) (int64, error)

	// Webhook targets (webhook v2, migration 00017) — additional POST
	// destinations next to the legacy single global webhook. Update
	// replaces the full mutable row (name/url/secret/events/enabled);
	// partial-PATCH merging happens in the handler.
	CreateWebhookTarget(ctx context.Context, t *model.WebhookTarget) (*model.WebhookTarget, error)
	GetWebhookTarget(ctx context.Context, id int64) (*model.WebhookTarget, error)
	ListWebhookTargets(ctx context.Context) ([]*model.WebhookTarget, error)
	UpdateWebhookTarget(ctx context.Context, t *model.WebhookTarget) error
	DeleteWebhookTarget(ctx context.Context, id int64) error
	// UpdateWebhookTargetDelivery records the outcome of the most recent
	// delivery attempt to one target (migration 00019): the final
	// attempt's HTTP status (0 = no response at all), the aggregated
	// error message ("" on success → stored NULL) and the attempt time.
	UpdateWebhookTargetDelivery(ctx context.Context, id int64, httpStatus int, errMsg string, at time.Time) error

	// Replica rules + failures + report + settings
	ListReplicaRules(ctx context.Context) ([]*model.ReplicaRule, error)
	GetReplicaRule(ctx context.Context, id int64) (*model.ReplicaRule, error)
	CreateReplicaRule(ctx context.Context, in *model.ReplicaRuleInput) (*model.ReplicaRule, error)
	UpdateReplicaRule(ctx context.Context, id int64, in *model.ReplicaRuleInput) (*model.ReplicaRule, error)
	DeleteReplicaRule(ctx context.Context, id int64) error

	// Failures are keyed by (storage, path, op) since migration 00094: a path
	// is relative to its storage, and several storages replicate at once.
	UpsertReplicaFailure(ctx context.Context, storageID int64, path, op, errCode, errMsg string) error
	ResolveReplicaFailure(ctx context.Context, storageID int64, path, op string) error
	ListReplicaFailures(ctx context.Context, onlyUnresolved bool, limit, offset int) ([]*model.ReplicaFailure, int64, error)
	CountUnresolvedReplicaFailures(ctx context.Context) (int64, error)
	CountRecentlyResolvedReplicaFailures(ctx context.Context, since time.Time) (int64, error)

	UpsertReplicaStatusReport(ctx context.Context, total, failed, repaired int64, summaryJSON []byte) error
	GetReplicaStatusReport(ctx context.Context) (*model.ReplicaStatusReport, error)

	GetReplicaSettings(ctx context.Context) (*model.ReplicaSettings, error)
	UpsertReplicaSettings(ctx context.Context, s *model.ReplicaSettings) error

	// Initial copies (migration 00094, internal/replica initial.go, #186): the
	// copy of the files a storage already held when it was linked to a
	// replication target. One row per storage. Written once for every engine
	// (ReplicaInitialCopySQL).
	// GetReplicaInitialCopy answers (nil, nil) when the storage has no row.
	GetReplicaInitialCopy(ctx context.Context, storageID int64) (*model.ReplicaInitialCopy, error)
	ListReplicaInitialCopies(ctx context.Context) ([]*model.ReplicaInitialCopy, error)
	// StartReplicaInitialCopy makes the storage's row a fresh copy to
	// targetID - unless one for that target is already there and restart is
	// false, in which case that row stands. started says which happened; the
	// row returned is the one that stands. now is Unix seconds.
	StartReplicaInitialCopy(ctx context.Context, storageID, targetID, now int64, restart bool) (c *model.ReplicaInitialCopy, started bool, err error)
	// ClaimReplicaInitialCopy takes the row for one slice of work: it succeeds
	// while the copy is not done, is still to targetID and nobody holds an
	// unexpired lease (lease_until < now). until is when the claim lapses.
	ClaimReplicaInitialCopy(ctx context.Context, storageID, targetID int64, owner string, now, until int64) (bool, error)
	// SaveReplicaInitialCopy writes c's progress, phase, cursor and lease
	// (c.LeaseOwner/LeaseUntil; an empty owner releases it) - only while
	// `owner` still holds the row. ok=false: the lease was lost.
	SaveReplicaInitialCopy(ctx context.Context, c *model.ReplicaInitialCopy, owner string) (ok bool, err error)
	DeleteReplicaInitialCopy(ctx context.Context, storageID int64) error

	// Replica links (migration 00095, internal/replica folder.go): the folder
	// each storage writes into on its target. Written once for every engine
	// (ReplicaLinkSQL).
	// GetReplicaLink answers (nil, nil) when the storage has no row.
	GetReplicaLink(ctx context.Context, storageID int64) (*model.ReplicaLink, error)
	ListReplicaLinks(ctx context.Context) ([]*model.ReplicaLink, error)
	// PutReplicaLink creates or replaces the storage's row. A folder another
	// storage on the same target holds is refused by the (target_id,
	// folder_key) unique key.
	PutReplicaLink(ctx context.Context, l *model.ReplicaLink) error
	DeleteReplicaLink(ctx context.Context, storageID int64) error

	/* calisma:d3 comments */
	// Node comments (migration 00020) — flat chronological threads on
	// file/folder nodes. List excludes soft-deleted rows and joins the
	// author display name; hard removal happens via the nodes FK CASCADE
	// plus DeleteNodeCommentsByNode in the trash purge hook.
	CreateNodeComment(ctx context.Context, c *model.NodeComment) (*model.NodeComment, error)
	GetNodeComment(ctx context.Context, id int64) (*model.NodeComment, error)
	ListNodeComments(ctx context.Context, nodeID int64) ([]*model.NodeComment, error)
	SoftDeleteNodeComment(ctx context.Context, id int64) error
	DeleteNodeCommentsByNode(ctx context.Context, nodeID int64) error

	// Drafts (migration 00064, issue #71) — a new document before its first
	// save. The rows are read joined to the draft file's node row: Path, Size,
	// Mime, Mtime and NodeLive come from there. A draft whose node is trashed
	// is kept (a restore brings it back); a draft whose node is purged goes
	// with it (FK CASCADE).
	CreateDraft(ctx context.Context, d *model.Draft) (*model.Draft, error)
	// GetDraftByKey returns the draft with that key whatever its node's state,
	// sql.ErrNoRows when there is none.
	GetDraftByKey(ctx context.Context, key string) (*model.Draft, error)
	// GetDraftByNode returns the draft whose file is that node, live or
	// trashed, sql.ErrNoRows when there is none.
	GetDraftByNode(ctx context.Context, nodeID int64) (*model.Draft, error)
	// ListLiveDrafts returns a person's drafts whose file is live, the most
	// recently made first.
	ListLiveDrafts(ctx context.Context, userID int64) ([]*model.Draft, error)
	// CountLiveDrafts is len(ListLiveDrafts) without reading the rows — the
	// limit check and the navigation panel's badge.
	CountLiveDrafts(ctx context.Context, userID int64) (int, error)
	DeleteDraft(ctx context.Context, id int64) error

	// Recent searches (migration 00090, task #168) - what a person searched
	// for in a search box that keeps its history on the server (the admin
	// panel's), newest first. Written once for every engine (RecentSearchSQL).
	// AddRecentSearch moves the same words to the top and keeps the newest
	// `keep`; DeleteRecentSearch answers ok=false for an id that is not the
	// person's own, exactly as for one that does not exist.
	AddRecentSearch(ctx context.Context, userID int64, surface, query string, keep int) (*model.RecentSearch, error)
	ListRecentSearches(ctx context.Context, userID int64, surface string, limit int) ([]*model.RecentSearch, error)
	DeleteRecentSearch(ctx context.Context, userID, id int64) (bool, error)
	ClearRecentSearches(ctx context.Context, userID int64, surface string) error

	// Plugin install requests (migration 00070, internal/pluginreq) — what an
	// API key leaves instead of installing a plugin, for an administrator to
	// approve or reject. Written once for every engine (PluginRequestSQL).
	CreatePluginRequest(ctx context.Context, r *model.PluginRequest) (*model.PluginRequest, error)
	// GetPluginRequest answers sql.ErrNoRows when there is no such request.
	GetPluginRequest(ctx context.Context, id int64) (*model.PluginRequest, error)
	// ListPluginRequests: one state ("" = all), newest first, at most limit.
	ListPluginRequests(ctx context.Context, status string, limit int) ([]*model.PluginRequest, error)
	// PendingPluginRequestBySource answers sql.ErrNoRows when no request for
	// that source is pending.
	PendingPluginRequestBySource(ctx context.Context, sourceKey string) (*model.PluginRequest, error)
	// UpdatePluginRequest writes the decision fields; with onlyIfPending only
	// while the stored row is still pending (ok = it was written).
	UpdatePluginRequest(ctx context.Context, r *model.PluginRequest, onlyIfPending bool) (bool, error)

	// The app store's state (migration 00081, internal/appstore): the
	// installation's id, the stores an administrator trusted, the paid apps'
	// licenses (the key sealed), the install links already used. Opaque rows,
	// a key and a JSON value; written once for every engine (AppStoreSQL).
	GetAppStoreState(ctx context.Context, key string) (string, bool, error)
	PutAppStoreState(ctx context.Context, key, value string) error
	// DeleteAppStoreState: ok = there was a row.
	DeleteAppStoreState(ctx context.Context, key string) (bool, error)
	// ListAppStoreState: every row whose key starts with prefix.
	ListAppStoreState(ctx context.Context, prefix string) (map[string]string, error)

	// File associations (migration 00077, internal/assoc): the administrator's
	// rule per kind of file and capability, and each app's thumbnail limits.
	// Written once for every engine (FileAssocSQL).
	ListFileAssociations(ctx context.Context) ([]*model.FileAssociation, error)
	PutFileAssociation(ctx context.Context, a *model.FileAssociation) error
	// DeleteFileAssociation: ok = there was a rule.
	DeleteFileAssociation(ctx context.Context, capability, ext string) (bool, error)
	// GetAppThumbLimits answers (nil, nil) when the app has none stored.
	GetAppThumbLimits(ctx context.Context, pluginID int64) (*model.AppThumbLimits, error)
	PutAppThumbLimits(ctx context.Context, l *model.AppThumbLimits) error
	DeleteAppThumbLimits(ctx context.Context, pluginID int64) error
	// ThumbnailGenerators counts the ready thumbnails by who drew them
	// (thumbnails.generator; "" for rows from before 0.50 and placeholder
	// cards). nil storageIDs is every storage; an empty slice is none.
	ThumbnailGenerators(ctx context.Context, storageIDs []int64) (map[string]int64, error)

	// Sign-in attempt counters (migration 00072, internal/loginguard): how many
	// wrong attempts an account identifier or an address has made, and until
	// when it is locked. Written once for every engine (LoginThrottleSQL).
	// GetLoginThrottle answers (nil, nil) when nothing was counted.
	GetLoginThrottle(ctx context.Context, scope, subject string) (*model.LoginThrottle, error)
	// SaveLoginThrottle creates or overwrites the counter for (Scope, Subject).
	SaveLoginThrottle(ctx context.Context, t *model.LoginThrottle) error
	// DeleteLoginThrottle removes one counter; ok = there was one.
	DeleteLoginThrottle(ctx context.Context, scope, subject string) (bool, error)
	// ListLoginThrottles: one scope ("" = all), only rows locked at lockedAt
	// when it is non-nil, most recently active first, at most limit.
	ListLoginThrottles(ctx context.Context, scope string, lockedAt *time.Time, limit int) ([]*model.LoginThrottle, error)
	// LoadLoginThrottles returns every counter PruneLoginThrottles(since)
	// would keep (a failure at or after since, or a lock running past it),
	// most recently active first, at most limit (<= 0: no limit). It is how
	// the limiter fills its memory at start.
	LoadLoginThrottles(ctx context.Context, since time.Time, limit int) ([]*model.LoginThrottle, error)
	// PruneLoginThrottles drops counters idle since before; n = rows removed.
	PruneLoginThrottles(ctx context.Context, before time.Time) (int64, error)

	// Office editing sessions (migration 00092, internal/onlyoffice
	// session_base.go, #184): the version of a document each ONLYOFFICE
	// editing session opened, by document key. Written once for every engine
	// (OfficeSessionSQL).
	// GetOfficeSession answers (nil, nil) when there is no row.
	GetOfficeSession(ctx context.Context, key string) (*model.OfficeSession, error)
	// PutOfficeSession creates or overwrites the row for s.DocKey.
	PutOfficeSession(ctx context.Context, s *model.OfficeSession) error
	// AddOfficeSession records s unless an unexpired row for its key is
	// there, and returns the row that stands (the first writer's).
	AddOfficeSession(ctx context.Context, s *model.OfficeSession, now time.Time) (*model.OfficeSession, error)
	// DeleteOfficeSession removes the row for key.
	DeleteOfficeSession(ctx context.Context, key string) error
	// PruneOfficeSessions removes the rows that expired before `before`.
	PruneOfficeSessions(ctx context.Context, before time.Time) (int64, error)

	// Editing together (migration 00107, internal/coedit, #189): see
	// CoEditStore, which internal/coedit asks for by name.
	CoEditStore

	// Vault write locks (migration 00096, internal/vaultlock,
	// docs/E2E-VAULT-FORMAT.md → The write lock): one row per tenant and
	// vault id, written by compare-and-set. Written once for every engine
	// (VaultLockSQL).
	// GetVaultLock answers (nil, nil) when the vault has no row.
	GetVaultLock(ctx context.Context, tenantID int64, vaultID string) (*model.VaultLock, error)
	// InsertVaultLock creates a vault's row at rev 1; ok = false when a row
	// was already there.
	InsertVaultLock(ctx context.Context, l *model.VaultLock) (bool, error)
	// UpdateVaultLock writes l over the row while it is still at rev; ok =
	// false when another writer came first.
	UpdateVaultLock(ctx context.Context, l *model.VaultLock, rev int64) (bool, error)
	// GetVaultIdleMinutes answers a person's idle time, 0 when never set.
	GetVaultIdleMinutes(ctx context.Context, userID int64) (int, error)
	// SetVaultIdleMinutes stores it (the caller checks 1 to 10).
	SetVaultIdleMinutes(ctx context.Context, userID int64, minutes int) error

	// Encryption requests (migration 00080, internal/e2epolicy) — what a
	// person leaves where the tenant's policy wants an administrator's
	// approval before a folder or a file is end-to-end encrypted. Written once
	// for every engine (E2ERequestSQL).
	CreateE2ERequest(ctx context.Context, r *model.E2ERequest) (*model.E2ERequest, error)
	// GetE2ERequest answers sql.ErrNoRows when there is no such request.
	GetE2ERequest(ctx context.Context, id int64) (*model.E2ERequest, error)
	// ListE2ERequests: what f matches, newest first — with f.ExpiresBefore,
	// what is due, the longest overdue first — at most 500.
	ListE2ERequests(ctx context.Context, f model.E2ERequestFilter) ([]*model.E2ERequest, error)
	// UpdateE2ERequest writes the state fields; with onlyIfStatus only while
	// the stored row is still in that state (ok = it was written).
	UpdateE2ERequest(ctx context.Context, r *model.E2ERequest, onlyIfStatus string) (bool, error)
	// FindApprovedE2ERequest answers an approval the person may still use
	// (approved, not expired at now), sql.ErrNoRows when there is none.
	FindApprovedE2ERequest(ctx context.Context, userID, storageID int64, path, kind string, now time.Time) (*model.E2ERequest, error)

	// Providers (tenants). See docs/MULTI-TENANCY.md. Inert while multi-tenant
	// mode is off; a single "default" provider always exists (migration 00014).
	CreateProvider(ctx context.Context, p *model.Provider) (*model.Provider, error)
	GetProvider(ctx context.Context, id int64) (*model.Provider, error)
	GetProviderBySlug(ctx context.Context, slug string) (*model.Provider, error)
	// GetProviderByHost resolves a request Host to its tenant; returns nil if no
	// enabled provider claims that host.
	GetProviderByHost(ctx context.Context, host string) (*model.Provider, error)
	// GetProviderByRealm resolves a realm (migration 00073) to its tenant, or
	// nil. The realm is compared normalised (package tenant); "" is nobody's.
	GetProviderByRealm(ctx context.Context, realm string) (*model.Provider, error)
	ListProviders(ctx context.Context) ([]*model.Provider, error)
	// UpdateProvider writes every column EXCEPT realm: a realm is set when the
	// tenant is created (CreateProvider) and never changes (package tenant).
	UpdateProvider(ctx context.Context, p *model.Provider) error
	DeleteProvider(ctx context.Context, id int64) error
	// GetSupertenant returns the single is_supertenant provider, or nil.
	GetSupertenant(ctx context.Context) (*model.Provider, error)

	// Provider ↔ storage links (M:N; 1:1 in the first UI).
	LinkProviderStorage(ctx context.Context, providerID, storageID int64) error
	UnlinkProviderStorage(ctx context.Context, providerID, storageID int64) error
	ListProviderStorageIDs(ctx context.Context, providerID int64) ([]int64, error)
	// GetProviderIDForStorage returns the (first) provider a storage is linked
	// to — used by background workers to derive tenancy from a storage.
	GetProviderIDForStorage(ctx context.Context, storageID int64) (int64, bool, error)

	/* kimlik:e3 cloud */
	// Provider plan metadata (cloud preparation, migration 00021 — see
	// docs/CLOUD.md). The columns are nullable and PASSIVE: only the
	// FILEX_CLOUD signup skeleton reads/writes them; with the flag off
	// (default) nothing touches these methods.
	SetProviderPlan(ctx context.Context, providerID int64, plan, limitsJSON, billingRef string) error
	GetProviderPlan(ctx context.Context, providerID int64) (plan, limitsJSON, billingRef string, err error)

	// Tenant self-service (migration 00076, docs/TENANT-ADMIN.md; TenantAuthSQL,
	// one implementation for every engine).
	//
	// Sign-in provider instances and which tenant signs in through which.
	ListAuthInstances(ctx context.Context) ([]*model.AuthInstance, error)
	GetAuthInstance(ctx context.Context, id int64) (*model.AuthInstance, error)
	GetAuthInstanceBySlug(ctx context.Context, slug string) (*model.AuthInstance, error)
	CreateAuthInstance(ctx context.Context, a *model.AuthInstance) (*model.AuthInstance, error)
	// UpdateAuthInstance writes label, enabled, legacy, configuration and
	// promoted scopes; never the slug, driver, origin or owner.
	UpdateAuthInstance(ctx context.Context, a *model.AuthInstance) error
	DeleteAuthInstance(ctx context.Context, id int64) error
	ListAuthBindings(ctx context.Context) ([]*model.AuthBinding, error)
	BindAuthInstance(ctx context.Context, providerID, instanceID int64, source string) error
	UnbindAuthInstance(ctx context.Context, providerID, instanceID int64) error
	// SetProviderAllowInsecureAuth is the operator's per-tenant switch;
	// UpdateProvider never writes the column.
	SetProviderAllowInsecureAuth(ctx context.Context, providerID int64, allow bool) error

	// A tenant's own domains (one tenant per domain).
	ListProviderDomains(ctx context.Context, providerID int64) ([]*model.ProviderDomain, error)
	GetProviderDomain(ctx context.Context, domain string) (*model.ProviderDomain, error)
	GetProviderDomainByID(ctx context.Context, id int64) (*model.ProviderDomain, error)
	CreateProviderDomain(ctx context.Context, d *model.ProviderDomain) (*model.ProviderDomain, error)
	SetProviderDomainStatus(ctx context.Context, id int64, status string, why model.DomainCheck, checkedAt time.Time) error
	SetProviderDomainCert(ctx context.Context, id int64, certPEM, keySealed string, notAfter *time.Time) error
	DeleteProviderDomain(ctx context.Context, id int64) error
	ProviderIDByActiveDomain(ctx context.Context, domain string) (int64, error)

	// Who may encrypt in a tenant (migration 00080, internal/e2epolicy): the
	// platform's ceiling and the tenant's policy. Dedicated accessors, like
	// the plan's, so the provider CRUD SQL stays as it was and no caller that
	// builds a model.Provider (the admin API, the cloud signup) can switch a
	// tenant's encryption off by leaving two new fields at their zero values.
	// GetProviderE2E answers sql.ErrNoRows for no such provider;
	// SetProviderE2E refuses a policy that is not one of the four.
	//
	// SetProviderE2E writes both columns: the whole-row setter tests and
	// fixtures use. The handlers change one column with the single-column
	// setters below.
	GetProviderE2E(ctx context.Context, providerID int64) (model.ProviderE2E, error)
	SetProviderE2E(ctx context.Context, providerID int64, e model.ProviderE2E) error

	// SetProviderE2EPolicy writes the tenant's policy alone, refusing one that
	// is not one of the four as SetProviderE2E does; SetProviderE2EAllowed
	// writes the platform's ceiling alone. With several filex instances on one
	// database, a tenant administrator's policy change and the operator's
	// ceiling change can land between each other's read and write, and a
	// statement that names only its own column is what keeps the other's.
	//
	// Like SetProviderE2E, both answer nil for a provider id that does not
	// exist, and neither reads RowsAffected to tell: MySQL reports CHANGED
	// rows, so writing the value already stored counts 0 there. Callers look
	// the tenant up first.
	SetProviderE2EPolicy(ctx context.Context, providerID int64, policy string) error
	SetProviderE2EAllowed(ctx context.Context, providerID int64, allowed bool) error
}

Store is the interface implemented by every dialect-specific query adapter. Methods are intentionally tiny domain operations — handlers should never reach into *sql.DB directly.

In a fully-generated setup this surface would be sqlc's Querier interface; here we hand-roll it so the skeleton compiles without a `sqlc generate` step.

type TenantAuthSQL added in v0.50.0

type TenantAuthSQL struct {
	// contains filtered or unexported fields
}

TenantAuthSQL implements the tenant self-service half of Store (migration 00076, docs/TENANT-ADMIN.md): sign-in provider instances, which tenant signs in through which, a tenant's own domains, and the operator's per-tenant switch for insecure providers. Written ONCE for every engine and embedded in each driver's Store, the arrangement of GroupSQL.

Every statement runs on Conn(ctx, pool), so a caller's RunInTx holds it too; nothing here opens a transaction of its own except where two statements must agree (BindAuthInstance), and that one joins the caller's.

func NewTenantAuthSQL added in v0.50.0

func NewTenantAuthSQL(conn *sql.DB, dollar bool) *TenantAuthSQL

NewTenantAuthSQL returns the store for conn; dollar for PostgreSQL.

func (*TenantAuthSQL) BindAuthInstance added in v0.50.0

func (s *TenantAuthSQL) BindAuthInstance(ctx context.Context, providerID, instanceID int64, source string) error

BindAuthInstance binds a tenant to an instance. Binding twice is not an error, and the first binding's source is kept.

Check, then insert, inside one transaction (the caller's when it has one): no upsert clause, so nothing differs per engine.

func (*TenantAuthSQL) CreateAuthInstance added in v0.50.0

func (s *TenantAuthSQL) CreateAuthInstance(ctx context.Context, a *model.AuthInstance) (*model.AuthInstance, error)

CreateAuthInstance inserts an instance. A slug another instance has is the unique index's refusal.

func (*TenantAuthSQL) CreateProviderDomain added in v0.50.0

func (s *TenantAuthSQL) CreateProviderDomain(ctx context.Context, d *model.ProviderDomain) (*model.ProviderDomain, error)

CreateProviderDomain adds a domain to a tenant, pending. A domain any tenant already has is the unique index's refusal (one tenant at a time).

func (*TenantAuthSQL) DeleteAuthInstance added in v0.50.0

func (s *TenantAuthSQL) DeleteAuthInstance(ctx context.Context, id int64) error

DeleteAuthInstance removes an instance; its bindings go with it.

func (*TenantAuthSQL) DeleteProviderDomain added in v0.50.0

func (s *TenantAuthSQL) DeleteProviderDomain(ctx context.Context, id int64) error

DeleteProviderDomain removes a domain from its tenant.

func (*TenantAuthSQL) GetAuthInstance added in v0.50.0

func (s *TenantAuthSQL) GetAuthInstance(ctx context.Context, id int64) (*model.AuthInstance, error)

GetAuthInstance returns one instance, or (nil, nil).

func (*TenantAuthSQL) GetAuthInstanceBySlug added in v0.50.0

func (s *TenantAuthSQL) GetAuthInstanceBySlug(ctx context.Context, slug string) (*model.AuthInstance, error)

GetAuthInstanceBySlug returns one instance by its slug, or (nil, nil).

func (*TenantAuthSQL) GetProviderDomain added in v0.50.0

func (s *TenantAuthSQL) GetProviderDomain(ctx context.Context, domain string) (*model.ProviderDomain, error)

GetProviderDomain returns the row of a domain (lower case), or (nil, nil).

func (*TenantAuthSQL) GetProviderDomainByID added in v0.50.0

func (s *TenantAuthSQL) GetProviderDomainByID(ctx context.Context, id int64) (*model.ProviderDomain, error)

GetProviderDomainByID returns one row, or (nil, nil).

func (*TenantAuthSQL) ListAuthBindings added in v0.50.0

func (s *TenantAuthSQL) ListAuthBindings(ctx context.Context) ([]*model.AuthBinding, error)

ListAuthBindings returns every binding, by tenant then instance.

func (*TenantAuthSQL) ListAuthInstances added in v0.50.0

func (s *TenantAuthSQL) ListAuthInstances(ctx context.Context) ([]*model.AuthInstance, error)

ListAuthInstances returns every instance, in id order.

func (*TenantAuthSQL) ListProviderDomains added in v0.50.0

func (s *TenantAuthSQL) ListProviderDomains(ctx context.Context, providerID int64) ([]*model.ProviderDomain, error)

ListProviderDomains returns a tenant's domains (every tenant's for 0), by domain.

func (*TenantAuthSQL) ProviderIDByActiveDomain added in v0.50.0

func (s *TenantAuthSQL) ProviderIDByActiveDomain(ctx context.Context, domain string) (int64, error)

ProviderIDByActiveDomain returns the tenant an ACTIVE own domain routes to, 0 for none. It does not read the providers row itself: the engine's GetProviderByHost does, so the row is scanned the engine's way and its `enabled` is judged there.

func (*TenantAuthSQL) SetProviderAllowInsecureAuth added in v0.50.0

func (s *TenantAuthSQL) SetProviderAllowInsecureAuth(ctx context.Context, providerID int64, allow bool) error

SetProviderAllowInsecureAuth is the platform operator's per-tenant switch (providers.allow_insecure_auth). Its own statement: UpdateProvider never writes the column, so a tenant edit cannot carry it along.

func (*TenantAuthSQL) SetProviderDomainCert added in v0.50.0

func (s *TenantAuthSQL) SetProviderDomainCert(ctx context.Context, id int64, certPEM, keySealed string, notAfter *time.Time) error

SetProviderDomainCert stores (or, with empty values, removes) the certificate a tenant brought for its domain. The key arrives sealed.

func (*TenantAuthSQL) SetProviderDomainStatus added in v0.50.0

func (s *TenantAuthSQL) SetProviderDomainStatus(ctx context.Context, id int64, status string, why model.DomainCheck, checkedAt time.Time) error

SetProviderDomainStatus records a check: the state it left the domain in, what it found when that was not the CNAME (the sentence, and its code and names for a screen to say), and when. active_since is set when the domain becomes active and kept while it stays so; it is cleared when it stops being active.

func (*TenantAuthSQL) UnbindAuthInstance added in v0.50.0

func (s *TenantAuthSQL) UnbindAuthInstance(ctx context.Context, providerID, instanceID int64) error

UnbindAuthInstance removes one binding (nothing when there is none).

func (*TenantAuthSQL) UpdateAuthInstance added in v0.50.0

func (s *TenantAuthSQL) UpdateAuthInstance(ctx context.Context, a *model.AuthInstance) error

UpdateAuthInstance writes what may change about an instance: its label, switch, legacy mark, configuration and promoted scopes. ⚠ Never the slug, the driver, the origin or the owner: an instance is what it was made as, and a tenant's own instance never becomes another tenant's.

type TrashTally

type TrashTally struct {
	Count int
	Bytes int64
}

TrashTally is one storage's share of the trash a purge sweep will walk.

type UnavailableEntry added in v0.50.0

type UnavailableEntry struct {
	NodeID int64
	Path   string
	Reason string
	Since  *time.Time
}

UnavailableEntry is the row that makes a path unavailable: the path itself, or the folder above it that the storage could not answer for.

type UserMetaEntry added in v0.54.0

type UserMetaEntry struct {
	NodeID int64
	At     time.Time
}

UserMetaEntry is one (node, time) of a person's per-node flag: when they opened it (last_opened) or starred it (starred).

type UserMetaPageSQL added in v0.54.0

type UserMetaPageSQL struct {
	Pool *sql.DB
	// Placeholders turns `?` into the engine's own (PostgreSQL:
	// DollarPlaceholders). Nil for SQLite and MySQL.
	Placeholders func(q string) string
}

UserMetaPageSQL implements Store.UserNodeMetaPage and Store.UserNodeMetaAt, written ONCE for every engine (the arrangement of NodeLinkStateSQL): each driver embeds a *UserMetaPageSQL in its Store.

⚠ Why (filex 0.54, audit D3 + D4). Recent and Starred were read with ListNodesByUserMeta: the newest N rows and nothing else - no total, no offset, no other order, and no WHEN on the row. The explorer asked for 50 recents and 200 stars, could not say the list was cut, and drew "Recent" in the file's modification order because the opening time never reached it. And to put a star on a listing row it fetched the first 500 stars and matched ids in the browser, so a person's 501st star was never drawn.

The page reads only ids and times; the caller loads the rows it returns (GetNode), so there is no per-driver node scan to keep in step here.

func (*UserMetaPageSQL) UserNodeMetaAt added in v0.54.0

func (o *UserMetaPageSQL) UserNodeMetaAt(ctx context.Context, userID int64, key string, ids []int64) (map[int64]time.Time, error)

UserNodeMetaAt returns, for the ids userID flagged with key, when each was flagged. An id the person never flagged is absent from the map - which is how a listing learns, in one query per page, which of its rows are starred.

func (*UserMetaPageSQL) UserNodeMetaPage added in v0.54.0

func (o *UserMetaPageSQL) UserNodeMetaPage(ctx context.Context, userID int64, key string, order listorder.Order, limit, offset int) ([]UserMetaEntry, int, error)

UserNodeMetaPage returns one page of the live nodes userID flagged with key, in order o (listorder.Newest when the caller asked for none), with the time each was flagged, and how many there are in all.

type UserPrefsDoc added in v0.50.0

type UserPrefsDoc struct {
	Surface   string
	Doc       string
	UpdatedAt time.Time
}

NodeAgg is a lightweight node row used for folder-size aggregation (internal/sync.RecomputeFolderSizes): just enough to walk the tree and sum descendant file sizes into each folder's cached size. UserPrefsDoc is one stored preference document (00047): the surface it belongs to, the JSON and when it was last written.

type UserTenant added in v0.50.0

type UserTenant struct {
	ProviderID int64
	Main       bool
}

UserTenant is the tenant an account lookup stays inside: the tenant's row, and Main for the platform's own tenant, which also owns an account with no tenant at all (provider_id NULL, a bootstrap or legacy row; the same rule as identity.Tenant.Owns).

type VanishedSQL added in v0.48.0

type VanishedSQL struct {
	Pool *sql.DB
	// Placeholders turns `?` into the engine's own (PostgreSQL:
	// DollarPlaceholders). Nil for SQLite and MySQL.
	Placeholders func(q string) string
}

VanishedSQL implements Store.ListVanishedNodeIDs and Store.CountChildRows (issue #74), written ONCE for every engine. Each driver embeds a *VanishedSQL in its Store, so the methods are promoted and there is no per-driver copy to drift (the arrangement of NodeDeletedBySQL).

A "vanished" row is one soft-deleted WHERE IT STOOD: deleted_at is set and its path is still where the file lived, not a key inside `.filex-trash/`. Its bytes were never parked in the trash — the storage sync found the file gone (every version up to 0.47), or the bytes were deleted outright — so it is not a trash entry and nothing can restore it. See trash.Vanished.

func (*VanishedSQL) CountChildRows added in v0.48.0

func (o *VanishedSQL) CountChildRows(ctx context.Context, parentID int64) (int, error)

CountChildRows counts the rows, live or deleted, whose parent_id is parentID — everything the parent_id cascade would take with the row.

func (*VanishedSQL) ListVanishedNodeIDs added in v0.48.0

func (o *VanishedSQL) ListVanishedNodeIDs(ctx context.Context, storageID, afterID int64, limit int) ([]int64, error)

ListVanishedNodeIDs returns up to limit ids, above afterID and in id order, of storageID's vanished rows: deleted, and with a path that is neither the trash directory nor inside it, in either spelling the path column carries.

⚠ The trash prefix is matched with SUBSTR on the CHARACTER count (see PrefixChars), never LIKE. What is BELOW a folder is a byte range of path that an index answers (NodesUnderSQL); "not in the trash" has no range.

type VaultLockSQL added in v0.54.0

type VaultLockSQL struct {
	Pool *sql.DB
	// Placeholders turns `?` into the engine's own (PostgreSQL:
	// DollarPlaceholders). Nil for SQLite and MySQL.
	Placeholders func(q string) string
}

VaultLockSQL implements the vault_locks and vault_prefs half of Store (migration 00096, internal/vaultlock, docs/E2E-VAULT-FORMAT.md → The write lock), written ONCE for every engine and embedded in each driver's Store - the arrangement of OfficeSessionSQL.

Every column is an integer or text: times are Unix milliseconds, so nothing here depends on how an engine stores or compares a timestamp. Taking and renewing a lock are compare-and-set updates on `rev`: two processes on one database that race for the same vault cannot both win.

func (*VaultLockSQL) GetVaultIdleMinutes added in v0.54.0

func (p *VaultLockSQL) GetVaultIdleMinutes(ctx context.Context, userID int64) (int, error)

GetVaultIdleMinutes returns the idle time a person set (1 to 10), or 0 when they never set one.

func (*VaultLockSQL) GetVaultLock added in v0.54.0

func (p *VaultLockSQL) GetVaultLock(ctx context.Context, tenantID int64, vaultID string) (*model.VaultLock, error)

GetVaultLock returns the row of one vault, or (nil, nil) when there is none.

func (*VaultLockSQL) InsertVaultLock added in v0.54.0

func (p *VaultLockSQL) InsertVaultLock(ctx context.Context, l *model.VaultLock) (bool, error)

InsertVaultLock creates the row of a vault that has none, at rev 1. ok is false when a row is already there - another writer created it first, and the caller reads that one and tries again.

func (*VaultLockSQL) SetVaultIdleMinutes added in v0.54.0

func (p *VaultLockSQL) SetVaultIdleMinutes(ctx context.Context, userID int64, minutes int) error

SetVaultIdleMinutes stores a person's idle time. The range (1 to 10) is the caller's to check.

⚠ UPDATE-then-INSERT, as PutOfficeSession, because the upsert spellings differ per engine and this file is written once.

func (*VaultLockSQL) UpdateVaultLock added in v0.54.0

func (p *VaultLockSQL) UpdateVaultLock(ctx context.Context, l *model.VaultLock, rev int64) (bool, error)

UpdateVaultLock writes every column of l over the row - only while the row is still at rev (the revision the caller read), and moves it to rev+1. ok is false when another writer came first: the caller has lost the race and reads the row again.

Directories

Path Synopsis
drivers
mysql
Package mysql is the MySQL/MariaDB DB driver.
Package mysql is the MySQL/MariaDB DB driver.
postgres
Package postgres is the PostgreSQL DB driver.
Package postgres is the PostgreSQL DB driver.
sqlite
Package sqlite is the SQLite DB driver.
Package sqlite is the SQLite DB driver.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL