@firetail/finchly 0.0.7 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,18 @@ All notable changes to `@firetail/finchly`. Versions follow semver.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.0.8 - 2026-11-12
8
+
9
+ - **Breaking:** the database is per tenant. `connectDatabase` no longer
10
+ runs SQL itself: `database.forTenant(caller.tenantId)` gives the handle
11
+ with `execute`, `transaction` and `withAdvisoryLock`, and everything on
12
+ it happens in that tenant's schema. **Needs a platform that gives a
13
+ schema per tenant.**
14
+ - `database.migrate` applies migrations in every tenant's schema and
15
+ resolves with the versions applied, by tenant id. A tenant that starts
16
+ using the service later is migrated on first use.
17
+ - `database.tenants()` lists the tenants the service has a database for.
18
+
7
19
  ## 0.0.7 - 2026-11-12
8
20
 
9
21
  - An identity names its tenant: `caller.tenantId`. One deployment serves
package/README.md CHANGED
@@ -176,8 +176,8 @@ an app.
176
176
 
177
177
  ### A database of its own
178
178
 
179
- A service whose manifest sets `permissions.db` is given a PostgreSQL role
180
- and a schema that only that role can use. The service installs `pg` itself:
179
+ A service whose manifest sets `permissions.db` is given a PostgreSQL schema
180
+ of its own for each tenant that uses it. The service installs `pg` itself:
181
181
 
182
182
  ```
183
183
  npm install pg
@@ -186,9 +186,13 @@ npm install pg
186
186
  ```js
187
187
  import { connectDatabase } from "@firetail/finchly";
188
188
 
189
- // Once, at startup. Asks the platform for the role and schema it made.
190
- const db = await connectDatabase({ service });
191
- await db.migrate(new URL("./migrations/", import.meta.url).pathname);
189
+ // Once, at startup. Asks the platform for the role and schemas it made,
190
+ // and brings every tenant's schema up to date.
191
+ const database = await connectDatabase({ service });
192
+ await database.migrate(new URL("./migrations/", import.meta.url).pathname);
193
+
194
+ // Per request: the database of the caller's tenant.
195
+ const db = database.forTenant(caller.tenantId);
192
196
 
193
197
  const { rows } = await db.execute("SELECT id, body FROM notes WHERE author = $1", [caller.subject]);
194
198
 
@@ -197,28 +201,38 @@ await db.transaction(async (tx) => {
197
201
  await tx.execute("UPDATE authors SET notes = notes + 1 WHERE id = $1", [caller.subject]);
198
202
  });
199
203
 
200
- // One instance at a time, across every instance of this service.
204
+ // One instance at a time, for this tenant, across every instance of this service.
201
205
  await db.withAdvisoryLock("nightly-import", async (tx) => {
202
206
  // ...
203
207
  });
204
208
  ```
205
209
 
206
- - **Names need no schema.** `notes` means the service's own table. The
207
- service cannot read the platform's tables or another service's.
210
+ - **There is no database without a tenant.** SQL is run on the handle
211
+ `forTenant` gives, and happens in that tenant's schema. Take the tenant
212
+ id from a verified identity, never from the request itself.
213
+ - **Names need no schema.** `notes` means that tenant's table. The service
214
+ cannot read the platform's tables or another service's, and working for
215
+ one tenant it cannot reach another's.
208
216
  - **Values go in the second argument**, as `$1`, `$2`. They are never built
209
217
  into the SQL.
210
218
  - **A transaction** commits if its function returns and rolls back if it
211
219
  throws.
212
220
  - **Migrations** are files named `NNNN_name.sql`, applied in order, once
213
- each, in one transaction. Every instance may call `migrate` at startup:
214
- they take turns. A migration that was applied is never edited; add a new
215
- one. A problem is a `FinchlyError` with code `migration_invalid`.
221
+ each, in every tenant's schema. Every instance may call `migrate` at
222
+ startup: they take turns. A tenant that starts using the service later is
223
+ migrated the first time its database is used. A migration that was
224
+ applied is never edited; add a new one. A problem is a `FinchlyError`
225
+ with code `migration_invalid`.
226
+ - **Work with no request behind it**, such as a schedule, runs once for
227
+ each tenant: `await database.tenants()` lists them.
216
228
  - `connectDatabase` rejects with `database_not_granted` if the manifest
217
229
  does not ask for a database, and with `database_unavailable` if the
218
- platform or the database cannot be reached.
230
+ platform or the database cannot be reached. Using a tenant the service
231
+ has no database for rejects with `database_not_granted`.
219
232
 
220
233
  To run a service on its own, without a platform, give the URL directly:
221
- `connectDatabase({ url, slug })`.
234
+ `connectDatabase({ url, slug })`. Each tenant's schema is then made the
235
+ first time it is used.
222
236
 
223
237
  ### What every request carries
224
238
 
@@ -228,7 +242,7 @@ release is asking:
228
242
  | Header | Value |
229
243
  |---|---|
230
244
  | `x-platform-sdk` | `@firetail/finchly` |
231
- | `x-platform-sdk-version` | This release, for example `0.0.7` |
245
+ | `x-platform-sdk-version` | This release, for example `0.0.8` |
232
246
  | `x-platform-api-version` | The platform API version it speaks, `v1` |
233
247
 
234
248
  That covers fetching the gateway's issuer and keys, registering an instance
package/dist/db.d.ts CHANGED
@@ -3,7 +3,7 @@ export interface QueryResult<Row> {
3
3
  readonly rows: Row[];
4
4
  readonly rowCount: number;
5
5
  }
6
- /** Something SQL can be run on: the database, or one transaction. */
6
+ /** Something SQL can be run on: a tenant's database, or one transaction in it. */
7
7
  export interface Queryable {
8
8
  /**
9
9
  * Run a statement. Values go in `params` and are referred to as $1, $2
@@ -12,24 +12,36 @@ export interface Queryable {
12
12
  */
13
13
  execute<Row = Record<string, unknown>>(sql: string, params?: readonly unknown[]): Promise<QueryResult<Row>>;
14
14
  }
15
- export interface Database extends Queryable {
16
- /** The service's schema, when the platform said which it is. Unqualified names already mean this schema. */
17
- readonly schema: string | undefined;
15
+ /** This service's database for one tenant. Unqualified names mean that tenant's tables. */
16
+ export interface TenantDatabase extends Queryable {
17
+ readonly tenantId: string;
18
18
  /** Run `fn` in one transaction. It commits if `fn` returns and rolls back if it throws. */
19
19
  transaction<T>(fn: (tx: Queryable) => Promise<T>): Promise<T>;
20
20
  /**
21
21
  * Run `fn` in a transaction that holds a lock named `key`. Another
22
- * instance of this service that asks for the same key waits until this
23
- * transaction ends. The lock is this service's own: another service
24
- * using the same name does not wait for it.
22
+ * instance of this service that asks for the same key, for the same
23
+ * tenant, waits until this transaction ends. The lock is this tenant's
24
+ * and this service's: the same name elsewhere does not wait for it.
25
25
  */
26
26
  withAdvisoryLock<T>(key: string, fn: (tx: Queryable) => Promise<T>): Promise<T>;
27
+ }
28
+ export interface Database {
29
+ /**
30
+ * The database for one tenant: `caller.tenantId` of the request being
31
+ * handled. Take the id from a verified identity, never from the request
32
+ * itself. Everything run on the handle happens in that tenant's schema.
33
+ */
34
+ forTenant(tenantId: string): TenantDatabase;
35
+ /** The tenants this service has a database for now. */
36
+ tenants(): Promise<string[]>;
27
37
  /**
28
- * Apply the migrations in `directory` that have not been applied: files
29
- * named NNNN_name.sql, in order, once each. Safe to call from every
30
- * instance at startup. Resolves with the versions this call applied.
38
+ * Apply the migrations in `directory`: files named NNNN_name.sql, in
39
+ * order, once each, in every tenant's schema. Safe to call from every
40
+ * instance at startup. A tenant that starts using the service later is
41
+ * migrated the first time its database is used. Resolves with the
42
+ * versions this call applied, by tenant id.
31
43
  */
32
- migrate(directory: string): Promise<string[]>;
44
+ migrate(directory: string): Promise<Record<string, string[]>>;
33
45
  close(): Promise<void>;
34
46
  }
35
47
  /** What the platform holds for this service. See spec/registration.md, section 4. */
@@ -37,7 +49,11 @@ export interface PlatformSettings {
37
49
  readonly slug: string;
38
50
  readonly database?: {
39
51
  readonly url: string;
40
- readonly schema: string;
52
+ /** By tenant id: the tenant's schema, and the role to take to work in it. */
53
+ readonly tenants: Readonly<Record<string, {
54
+ readonly schema: string;
55
+ readonly role: string;
56
+ }>>;
41
57
  };
42
58
  }
43
59
  export interface DatabaseOptions {
@@ -45,7 +61,8 @@ export interface DatabaseOptions {
45
61
  readonly service?: Pick<ServiceClient, "call">;
46
62
  /**
47
63
  * Connect to this URL and do not ask the platform. For tests and for
48
- * running a service on its own. Needs `slug`.
64
+ * running a service on its own. Needs `slug`. Each tenant's schema is
65
+ * made the first time it is used.
49
66
  */
50
67
  readonly url?: string;
51
68
  /** The service's slug. Only needed with `url`. */
@@ -57,7 +74,7 @@ export interface DatabaseOptions {
57
74
  export declare function fetchSettings(service: Pick<ServiceClient, "call">): Promise<PlatformSettings>;
58
75
  /**
59
76
  * Connect to this service's database. With `service`, the platform is
60
- * asked for the role and schema it made; the manifest must set
77
+ * asked for the role and schemas it made; the manifest must set
61
78
  * permissions.db. Rejects if the database cannot be reached.
62
79
  */
63
80
  export declare function connectDatabase(options: DatabaseOptions): Promise<Database>;
package/dist/db.js CHANGED
@@ -1,13 +1,22 @@
1
- // PostgreSQL for a service: the role and schema the platform made for it.
1
+ // PostgreSQL for a service: a schema of its own for each tenant.
2
2
  //
3
- // The platform gives each service that asks for one (permissions.db in the
4
- // manifest) a database role and a schema only that role can use. This
5
- // module asks the platform for them and connects. It needs the `pg`
6
- // package, which the service installs itself.
3
+ // One deployment serves several tenants. The platform gives a service that
4
+ // asks for a database (permissions.db in the manifest) a role to log in as,
5
+ // which can read nothing by itself, and for each tenant that uses the
6
+ // service a schema and a role that may use it. This module asks the
7
+ // platform for them and connects.
8
+ //
9
+ // Nothing can be read or written without naming a tenant: the handle that
10
+ // runs SQL is made with `forTenant`. It needs the `pg` package, which the
11
+ // service installs itself.
7
12
  import { createHash } from "node:crypto";
8
13
  import { readdir, readFile } from "node:fs/promises";
9
14
  import { join } from "node:path";
10
15
  import { FinchlyError } from "./errors.js";
16
+ const TENANT_ID = /^t_[a-z2-7]{16}$/;
17
+ const IDENTIFIER = /^[a-z][a-z0-9_]{0,62}$/;
18
+ /** The least time between two questions to the platform about a tenant it did not list. */
19
+ const SETTINGS_COOLDOWN_MS = 5_000;
11
20
  /** Ask the platform what it holds for this service. */
12
21
  export async function fetchSettings(service) {
13
22
  const response = await service.call("core", "/api/apps/self/config");
@@ -125,30 +134,51 @@ async function applyMigrations(tx, migrations) {
125
134
  }
126
135
  return done;
127
136
  }
137
+ /** A name the platform gave, quoted. Anything that is not such a name is refused, not escaped. */
138
+ function quoted(name) {
139
+ if (typeof name !== "string" || !IDENTIFIER.test(name)) {
140
+ throw new FinchlyError("database_unavailable", "The platform named a schema or role that is not usable");
141
+ }
142
+ return `"${name}"`;
143
+ }
144
+ function placesFrom(settings) {
145
+ const places = new Map();
146
+ for (const [tenantId, place] of Object.entries(settings.database?.tenants ?? {})) {
147
+ if (!TENANT_ID.test(tenantId))
148
+ continue;
149
+ // Checked now, so that a bad name is never sent to the database.
150
+ quoted(place.schema);
151
+ quoted(place.role);
152
+ places.set(tenantId, { schema: place.schema, role: place.role });
153
+ }
154
+ return places;
155
+ }
128
156
  /**
129
157
  * Connect to this service's database. With `service`, the platform is
130
- * asked for the role and schema it made; the manifest must set
158
+ * asked for the role and schemas it made; the manifest must set
131
159
  * permissions.db. Rejects if the database cannot be reached.
132
160
  */
133
161
  export async function connectDatabase(options) {
134
162
  let url;
135
163
  let slug;
136
- let schema;
164
+ let places = new Map();
165
+ const service = options.url === undefined ? options.service : undefined;
137
166
  if (options.url !== undefined) {
138
167
  if (!options.slug) {
139
168
  throw new FinchlyError("database_unavailable", "connectDatabase needs `slug` when it is given `url`");
140
169
  }
141
170
  ({ url, slug } = { url: options.url, slug: options.slug });
142
171
  }
143
- else if (options.service) {
144
- const settings = await fetchSettings(options.service);
172
+ else if (service) {
173
+ const settings = await fetchSettings(service);
145
174
  if (!settings.database) {
146
175
  throw new FinchlyError("database_not_granted", "The platform made no database for this service", [
147
176
  "Set permissions.db to true in the manifest",
148
177
  ]);
149
178
  }
150
- ({ url, schema } = settings.database);
179
+ url = settings.database.url;
151
180
  slug = settings.slug;
181
+ places = placesFrom(settings);
152
182
  }
153
183
  else {
154
184
  throw new FinchlyError("database_unavailable", "connectDatabase needs `service`, or `url` and `slug`");
@@ -157,7 +187,6 @@ export async function connectDatabase(options) {
157
187
  const pool = new pg.Pool({ connectionString: url, max: options.maxConnections ?? 10 });
158
188
  // An idle connection that drops must not take the process down.
159
189
  pool.on("error", () => { });
160
- const onPool = queryable(pool);
161
190
  try {
162
191
  await pool.query("SELECT 1");
163
192
  }
@@ -166,11 +195,64 @@ export async function connectDatabase(options) {
166
195
  // pg's message names the host and the role, never the password.
167
196
  throw new FinchlyError("database_unavailable", "The database could not be reached", [err.message]);
168
197
  }
169
- async function transaction(fn) {
198
+ /** Ask the platform again which tenants there are. At most one question at a time. */
199
+ let refreshing;
200
+ let refreshedAt = Date.now();
201
+ function refresh() {
202
+ if (!service)
203
+ return Promise.resolve();
204
+ refreshing ??= fetchSettings(service)
205
+ .then((settings) => {
206
+ places = placesFrom(settings);
207
+ refreshedAt = Date.now();
208
+ })
209
+ .finally(() => {
210
+ refreshing = undefined;
211
+ });
212
+ return refreshing;
213
+ }
214
+ /** Where a tenant's data is, or a refusal if this service has none for it. */
215
+ async function placeOf(tenantId) {
216
+ let place = places.get(tenantId);
217
+ if (place)
218
+ return place;
219
+ if (!service) {
220
+ // Running on its own: the schema is named as the platform would name
221
+ // it, and made by whoever is connected.
222
+ const schema = `${tenantId}_${slug.replaceAll("-", "_")}`.slice(0, 63);
223
+ try {
224
+ await pool.query(`CREATE SCHEMA IF NOT EXISTS ${quoted(schema)}`);
225
+ }
226
+ catch (err) {
227
+ // Two connections making it at once: one of them is told it exists.
228
+ const code = err.code;
229
+ if (code !== "23505" && code !== "42P06")
230
+ throw err;
231
+ }
232
+ place = { schema };
233
+ places.set(tenantId, place);
234
+ return place;
235
+ }
236
+ // A tenant may have started using the service since the platform was asked.
237
+ if (Date.now() - refreshedAt >= SETTINGS_COOLDOWN_MS || refreshing)
238
+ await refresh();
239
+ place = places.get(tenantId);
240
+ if (!place) {
241
+ throw new FinchlyError("database_not_granted", "This service has no database for that tenant", [
242
+ "The tenant does not use this service, or is not active",
243
+ ]);
244
+ }
245
+ return place;
246
+ }
247
+ /** Run `fn` in a transaction, as the tenant's role, in the tenant's schema. Both end with the transaction. */
248
+ async function inTenant(place, fn) {
170
249
  const client = await pool.connect();
171
250
  let broken;
172
251
  try {
173
252
  await client.query("BEGIN");
253
+ if (place.role !== undefined)
254
+ await client.query(`SET LOCAL ROLE ${quoted(place.role)}`);
255
+ await client.query(`SET LOCAL search_path = ${quoted(place.schema)}`);
174
256
  const result = await fn(queryable(client));
175
257
  await client.query("COMMIT");
176
258
  return result;
@@ -189,22 +271,64 @@ export async function connectDatabase(options) {
189
271
  client.release(broken);
190
272
  }
191
273
  }
192
- const withAdvisoryLock = (key, fn) => transaction(async (tx) => {
193
- // Advisory locks are shared by everything in the database, so the
194
- // name carries the service's slug.
195
- await tx.execute("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [`svc:${slug}:${key}`]);
196
- return fn(tx);
197
- });
198
- return {
199
- schema,
200
- execute: onPool.execute,
201
- transaction,
202
- withAdvisoryLock,
203
- async migrate(directory) {
204
- const migrations = await readMigrations(directory);
274
+ const lockKey = (tenantId, key) => `t:${tenantId}:svc:${slug}:${key}`;
275
+ const lock = (tx, tenantId, key) => tx.execute("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [lockKey(tenantId, key)]);
276
+ // Migrations, once `migrate` has been called: each tenant's schema is
277
+ // brought up to date once per process, before anything else runs in it.
278
+ let migrations;
279
+ const migrated = new Map();
280
+ function migrateTenant(tenantId, place) {
281
+ const known = migrations;
282
+ if (known === undefined)
283
+ return Promise.resolve([]);
284
+ let pending = migrated.get(tenantId);
285
+ if (!pending) {
205
286
  // One transaction: instances starting together take turns, and a
206
287
  // migration that fails leaves nothing behind.
207
- return withAdvisoryLock("migrate", (tx) => applyMigrations(tx, migrations));
288
+ pending = inTenant(place, async (tx) => {
289
+ await lock(tx, tenantId, "migrate");
290
+ return applyMigrations(tx, known);
291
+ });
292
+ migrated.set(tenantId, pending);
293
+ // A failure is reported to whoever asked, and tried again next time.
294
+ pending.catch(() => migrated.delete(tenantId));
295
+ }
296
+ return pending;
297
+ }
298
+ /** Run `fn` for a tenant, once its schema is known and up to date. */
299
+ async function run(tenantId, fn) {
300
+ const place = await placeOf(tenantId);
301
+ await migrateTenant(tenantId, place);
302
+ return inTenant(place, fn);
303
+ }
304
+ return {
305
+ forTenant(tenantId) {
306
+ if (typeof tenantId !== "string" || !TENANT_ID.test(tenantId)) {
307
+ throw new FinchlyError("database_unavailable", "forTenant needs a tenant id, as in caller.tenantId");
308
+ }
309
+ return {
310
+ tenantId,
311
+ execute: (sql, params) => run(tenantId, (tx) => tx.execute(sql, params)),
312
+ transaction: (fn) => run(tenantId, fn),
313
+ withAdvisoryLock: (key, fn) => run(tenantId, async (tx) => {
314
+ await lock(tx, tenantId, key);
315
+ return fn(tx);
316
+ }),
317
+ };
318
+ },
319
+ async tenants() {
320
+ await refresh();
321
+ return [...places.keys()].sort();
322
+ },
323
+ async migrate(directory) {
324
+ migrations = await readMigrations(directory);
325
+ migrated.clear();
326
+ await refresh();
327
+ const applied = {};
328
+ for (const [tenantId, place] of [...places].sort(([a], [b]) => a.localeCompare(b))) {
329
+ applied[tenantId] = await migrateTenant(tenantId, place);
330
+ }
331
+ return applied;
208
332
  },
209
333
  close: () => pool.end(),
210
334
  };
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { DEFAULT_MANIFEST_PATH, loadConfig } from "./config.js";
2
2
  export type { PlatformConfig } from "./config.js";
3
3
  export { connectDatabase, fetchSettings } from "./db.js";
4
- export type { Database, DatabaseOptions, PlatformSettings, QueryResult, Queryable } from "./db.js";
4
+ export type { Database, DatabaseOptions, PlatformSettings, QueryResult, Queryable, TenantDatabase } from "./db.js";
5
5
  export { FinchlyError, IdentityError } from "./errors.js";
6
6
  export type { FinchlyErrorCode, IdentityRejection } from "./errors.js";
7
7
  export { IDENTITY_HEADER, createIdentityVerifier } from "./identity.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firetail/finchly",
3
- "version": "0.0.7",
3
+ "version": "0.0.8",
4
4
  "description": "Node SDK for the Finchly platform",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",