@firetail/finchly 0.0.4 → 0.0.6
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 +17 -15
- package/README.md +56 -77
- package/dist/db.d.ts +63 -0
- package/dist/db.js +211 -0
- package/dist/errors.d.ts +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/package.json +12 -3
package/CHANGELOG.md
CHANGED
|
@@ -4,37 +4,39 @@ All notable changes to `@firetail/finchly`. Versions follow semver.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
-
## 0.0.
|
|
7
|
+
## 0.0.6 - 2026-11-12
|
|
8
|
+
|
|
9
|
+
- `connectDatabase`: PostgreSQL for a service whose manifest sets
|
|
10
|
+
`permissions.db`. It asks the platform for the role and schema made for
|
|
11
|
+
the service and connects with the `pg` package, which the service
|
|
12
|
+
installs itself. `execute`, `transaction`, `withAdvisoryLock`, `migrate`.
|
|
13
|
+
- `fetchSettings`: what the platform holds for the service.
|
|
14
|
+
- New error codes: `database_unavailable`, `database_not_granted`,
|
|
15
|
+
`migration_invalid`.
|
|
16
|
+
- `pg` is an optional peer dependency.
|
|
8
17
|
|
|
9
|
-
|
|
10
|
-
workflow: 0.0.2 and 0.0.3 were tagged but did not reach npm, because the
|
|
11
|
-
workflow published with a placeholder token in place of the trusted
|
|
12
|
-
publisher. Everything listed under them arrives in this release.
|
|
18
|
+
## 0.0.5 - 2026-11-12
|
|
13
19
|
|
|
14
|
-
|
|
20
|
+
- The README covers using the SDK only. Notes for people working on the
|
|
21
|
+
SDK are no longer part of the package.
|
|
15
22
|
|
|
16
|
-
|
|
23
|
+
## 0.0.4 - 2026-11-12
|
|
17
24
|
|
|
18
25
|
- Every request to the platform carries three headers: `x-platform-sdk`
|
|
19
26
|
(the package name), `x-platform-sdk-version` (this release) and
|
|
20
27
|
`x-platform-api-version` (the platform API version it speaks). A caller
|
|
21
28
|
cannot replace them. Exported as `SDK_REQUEST_HEADERS`.
|
|
22
|
-
|
|
23
|
-
## 0.0.2 - 2026-11-12
|
|
24
|
-
|
|
25
|
-
Tagged but not published to npm.
|
|
26
|
-
|
|
27
29
|
- An app can register itself: `createServiceClient` takes an `enrolment`
|
|
28
30
|
(token, manifest, address) in place of a credential, and
|
|
29
31
|
`enrolmentFrom(config, manifest)` builds one from the platform settings.
|
|
30
32
|
Registration is what gets an app's instance key accepted.
|
|
31
33
|
- New error codes: `app_pending_approval`, `app_registration_refused`.
|
|
32
34
|
|
|
35
|
+
0.0.2 and 0.0.3 were not published.
|
|
36
|
+
|
|
33
37
|
## 0.0.1 - 2026-11-11
|
|
34
38
|
|
|
35
|
-
The first release
|
|
36
|
-
trusted publishing can be set up. Later releases come from the release
|
|
37
|
-
workflow.
|
|
39
|
+
The first release.
|
|
38
40
|
|
|
39
41
|
- Package skeleton under its final name.
|
|
40
42
|
- `loadConfig`: read and validate the `PLATFORM_*` settings.
|
package/README.md
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
# @firetail/finchly
|
|
2
2
|
|
|
3
|
-
Node SDK for the [Finchly](https://github.com/
|
|
3
|
+
Node SDK for the [Finchly](https://github.com/FireTail-io/finchly) platform.
|
|
4
4
|
A service or app uses it to register with a Finchly deployment, verify who
|
|
5
5
|
is calling it, and reach the platform's database, object store, queue and
|
|
6
6
|
other services.
|
|
7
7
|
|
|
8
|
-
Status: early. The package builds and is tested, but only the first
|
|
8
|
+
Status: early. The package builds and is tested, but only the first five
|
|
9
9
|
modules exist.
|
|
10
10
|
|
|
11
11
|
The SDK implements the platform API, which is specified in
|
|
12
|
-
`
|
|
12
|
+
`FireTail-io/finchly` under `spec/`. This release speaks platform API `v1`
|
|
13
13
|
(draft).
|
|
14
14
|
|
|
15
15
|
## Install
|
|
@@ -69,7 +69,7 @@ try {
|
|
|
69
69
|
A token is rejected unless it is signed with ES256 by a key the gateway
|
|
70
70
|
publishes, names that gateway as issuer and this service as its only
|
|
71
71
|
audience, and has not expired. The rules are in `spec/identity-token.md` in
|
|
72
|
-
`
|
|
72
|
+
`FireTail-io/finchly`.
|
|
73
73
|
|
|
74
74
|
No other header is ever consulted. On a path the manifest declares in
|
|
75
75
|
`publicPaths` there is no token, and the service authenticates the request
|
|
@@ -115,7 +115,7 @@ The instance makes an ES256 key pair in memory the first time it is needed.
|
|
|
115
115
|
The private half never leaves the process. The public half is registered
|
|
116
116
|
with the gateway using the credential the service was deployed with, and
|
|
117
117
|
extended by itself for as long as the instance runs. The rules are in
|
|
118
|
-
`spec/service-token.md` and `spec/registration.md` in `
|
|
118
|
+
`spec/service-token.md` and `spec/registration.md` in `FireTail-io/finchly`.
|
|
119
119
|
|
|
120
120
|
### An app registers itself
|
|
121
121
|
|
|
@@ -153,13 +153,59 @@ exactly as it was loaded. What can come back:
|
|
|
153
153
|
| `app_registration_refused` | The platform refused, and `problems` says why: a bad token, a manifest or address it will not accept, a lower version than the one running | Fix the deployment. Trying again will not help |
|
|
154
154
|
| `gateway_unreachable` | The gateway could not be reached | Try again |
|
|
155
155
|
|
|
156
|
-
The protocol is in `spec/registration.md` in `
|
|
156
|
+
The protocol is in `spec/registration.md` in `FireTail-io/finchly`.
|
|
157
157
|
|
|
158
158
|
The other half of the `app` module is not built: serving the
|
|
159
159
|
`/{slug}/_platform/*` endpoints, which let an administrator register an app
|
|
160
160
|
that is already running, and fetching the settings the platform creates for
|
|
161
161
|
an app.
|
|
162
162
|
|
|
163
|
+
### A database of its own
|
|
164
|
+
|
|
165
|
+
A service whose manifest sets `permissions.db` is given a PostgreSQL role
|
|
166
|
+
and a schema that only that role can use. The service installs `pg` itself:
|
|
167
|
+
|
|
168
|
+
```
|
|
169
|
+
npm install pg
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
```js
|
|
173
|
+
import { connectDatabase } from "@firetail/finchly";
|
|
174
|
+
|
|
175
|
+
// Once, at startup. Asks the platform for the role and schema it made.
|
|
176
|
+
const db = await connectDatabase({ service });
|
|
177
|
+
await db.migrate(new URL("./migrations/", import.meta.url).pathname);
|
|
178
|
+
|
|
179
|
+
const { rows } = await db.execute("SELECT id, body FROM notes WHERE author = $1", [caller.subject]);
|
|
180
|
+
|
|
181
|
+
await db.transaction(async (tx) => {
|
|
182
|
+
await tx.execute("INSERT INTO notes (body, author) VALUES ($1, $2)", ["hello", caller.subject]);
|
|
183
|
+
await tx.execute("UPDATE authors SET notes = notes + 1 WHERE id = $1", [caller.subject]);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// One instance at a time, across every instance of this service.
|
|
187
|
+
await db.withAdvisoryLock("nightly-import", async (tx) => {
|
|
188
|
+
// ...
|
|
189
|
+
});
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
- **Names need no schema.** `notes` means the service's own table. The
|
|
193
|
+
service cannot read the platform's tables or another service's.
|
|
194
|
+
- **Values go in the second argument**, as `$1`, `$2`. They are never built
|
|
195
|
+
into the SQL.
|
|
196
|
+
- **A transaction** commits if its function returns and rolls back if it
|
|
197
|
+
throws.
|
|
198
|
+
- **Migrations** are files named `NNNN_name.sql`, applied in order, once
|
|
199
|
+
each, in one transaction. Every instance may call `migrate` at startup:
|
|
200
|
+
they take turns. A migration that was applied is never edited; add a new
|
|
201
|
+
one. A problem is a `FinchlyError` with code `migration_invalid`.
|
|
202
|
+
- `connectDatabase` rejects with `database_not_granted` if the manifest
|
|
203
|
+
does not ask for a database, and with `database_unavailable` if the
|
|
204
|
+
platform or the database cannot be reached.
|
|
205
|
+
|
|
206
|
+
To run a service on its own, without a platform, give the URL directly:
|
|
207
|
+
`connectDatabase({ url, slug })`.
|
|
208
|
+
|
|
163
209
|
### What every request carries
|
|
164
210
|
|
|
165
211
|
Every request this SDK makes to the platform says which SDK and which
|
|
@@ -168,7 +214,7 @@ release is asking:
|
|
|
168
214
|
| Header | Value |
|
|
169
215
|
|---|---|
|
|
170
216
|
| `x-platform-sdk` | `@firetail/finchly` |
|
|
171
|
-
| `x-platform-sdk-version` | This release, for example `0.0.
|
|
217
|
+
| `x-platform-sdk-version` | This release, for example `0.0.6` |
|
|
172
218
|
| `x-platform-api-version` | The platform API version it speaks, `v1` |
|
|
173
219
|
|
|
174
220
|
That covers fetching the gateway's issuer and keys, registering an instance
|
|
@@ -198,7 +244,7 @@ not leak them.
|
|
|
198
244
|
| `identity` | Verify `x-platform-identity` | Done. Directory lookups (`listUsers`, `membersOf`) and `require*` helpers are planned |
|
|
199
245
|
| `app` | Register with the platform, serve `/_platform/*`, renew keys, fetch settings | Self-registration and key renewal are done, through `service`. Serving `/_platform/*` and fetching settings are not |
|
|
200
246
|
| `service` | Call other services through the gateway, as the service or for a user | Done, for built-in services and for apps |
|
|
201
|
-
| `db` | PostgreSQL: queries, transactions, advisory locks, migrations |
|
|
247
|
+
| `db` | PostgreSQL: queries, transactions, advisory locks, migrations | Done |
|
|
202
248
|
| `blobs` | Object storage with conditional writes | Planned |
|
|
203
249
|
| `queue` | Publish, lease, ack, nack | Planned |
|
|
204
250
|
| `secrets` | Secrets and envelope encryption | Planned |
|
|
@@ -209,73 +255,6 @@ not leak them.
|
|
|
209
255
|
The manifest check here is not the full schema. The platform runs the full
|
|
210
256
|
check at registration and is the authority.
|
|
211
257
|
|
|
212
|
-
##
|
|
213
|
-
|
|
214
|
-
```
|
|
215
|
-
npm install
|
|
216
|
-
npm test
|
|
217
|
-
```
|
|
258
|
+
## Changes
|
|
218
259
|
|
|
219
|
-
|
|
220
|
-
build, which is what a consumer imports.
|
|
221
|
-
|
|
222
|
-
### Conformance vectors
|
|
223
|
-
|
|
224
|
-
`test/conformance/` holds test vectors copied from `firetail-io/finchly`
|
|
225
|
-
(`conformance/vectors/`). Both SDKs must reach the verdict each vector
|
|
226
|
-
states. Do not edit them here: change them in `finchly`, regenerate, and
|
|
227
|
-
copy the file to both SDK repositories. Until `finchly` publishes its
|
|
228
|
-
conformance suite as an image, the copy is made by hand.
|
|
229
|
-
|
|
230
|
-
## Release
|
|
231
|
-
|
|
232
|
-
Each package version is semver, independent of the Python SDK and of the
|
|
233
|
-
platform.
|
|
234
|
-
|
|
235
|
-
1. Changes land on `main` by pull request. Each one adds a line to
|
|
236
|
-
`CHANGELOG.md` under "Unreleased".
|
|
237
|
-
2. To release, set the version in `package.json`, move the changelog
|
|
238
|
-
entries under that version, merge, and tag the merge commit `vX.Y.Z`.
|
|
239
|
-
3. The `release` workflow checks the tag matches `package.json`, runs the
|
|
240
|
-
tests, and publishes.
|
|
241
|
-
4. A version with a pre-release part (`1.2.0-rc.1`) is published under the
|
|
242
|
-
`next` dist-tag. Anything else is published as `latest`.
|
|
243
|
-
5. A bad release is deprecated with `npm deprecate` and followed by a patch
|
|
244
|
-
release. It is not unpublished.
|
|
245
|
-
|
|
246
|
-
Publishing is meant to use npm trusted publishing: the workflow proves its
|
|
247
|
-
identity to npm with its GitHub Actions token, so no npm token is stored,
|
|
248
|
-
and npm attaches a provenance attestation.
|
|
249
|
-
|
|
250
|
-
npm matches the trusted publisher against the identity GitHub issues to the
|
|
251
|
-
workflow, and every field must match exactly, including case:
|
|
252
|
-
|
|
253
|
-
| Field on npmjs.com | Value |
|
|
254
|
-
| -------------------- | ------------- |
|
|
255
|
-
| Organization or user | `FireTail-io` |
|
|
256
|
-
| Repository | `finchly-sdk-js` |
|
|
257
|
-
| Workflow filename | `release.yml` |
|
|
258
|
-
| Environment name | `npm` |
|
|
259
|
-
|
|
260
|
-
The organisation is `FireTail-io`, not `firetail-io`. GitHub accepts either
|
|
261
|
-
spelling in a URL; npm does not, and answers "package not found" when the
|
|
262
|
-
two differ. `repository.url` in `package.json` must use the same spelling.
|
|
263
|
-
The workflow prints the identity it was issued, so a refusal can be compared
|
|
264
|
-
with what npmjs.com holds. A trusted publisher's fields cannot be edited: to
|
|
265
|
-
correct one, delete it and create another.
|
|
266
|
-
|
|
267
|
-
The workflow can also be given a token, for a release that trusted
|
|
268
|
-
publishing refuses:
|
|
269
|
-
|
|
270
|
-
- `NPM_TOKEN`, a secret of the `npm` environment in this repository. It is
|
|
271
|
-
a granular access token that can publish `@firetail/finchly` and nothing
|
|
272
|
-
else, with a short expiry.
|
|
273
|
-
- npm tries the trusted publisher first and uses the token only when that
|
|
274
|
-
is refused. Without the secret, nothing in the workflow needs to change.
|
|
275
|
-
- While a token is in use, the package's "Publishing access" on npmjs.com
|
|
276
|
-
must allow a granular access token with bypass 2FA.
|
|
277
|
-
- No provenance attestation is attached to a release published with the
|
|
278
|
-
token. npm also does not generate one from a private repository.
|
|
279
|
-
|
|
280
|
-
Still to add: the conformance suite from `firetail-io/finchly`, run in CI on
|
|
281
|
-
every change, once that suite exists.
|
|
260
|
+
See `CHANGELOG.md`.
|
package/dist/db.d.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { ServiceClient } from "./service.js";
|
|
2
|
+
export interface QueryResult<Row> {
|
|
3
|
+
readonly rows: Row[];
|
|
4
|
+
readonly rowCount: number;
|
|
5
|
+
}
|
|
6
|
+
/** Something SQL can be run on: the database, or one transaction. */
|
|
7
|
+
export interface Queryable {
|
|
8
|
+
/**
|
|
9
|
+
* Run a statement. Values go in `params` and are referred to as $1, $2
|
|
10
|
+
* and so on; never build them into the SQL. With no `params`, `sql` may
|
|
11
|
+
* hold several statements.
|
|
12
|
+
*/
|
|
13
|
+
execute<Row = Record<string, unknown>>(sql: string, params?: readonly unknown[]): Promise<QueryResult<Row>>;
|
|
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;
|
|
18
|
+
/** Run `fn` in one transaction. It commits if `fn` returns and rolls back if it throws. */
|
|
19
|
+
transaction<T>(fn: (tx: Queryable) => Promise<T>): Promise<T>;
|
|
20
|
+
/**
|
|
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.
|
|
25
|
+
*/
|
|
26
|
+
withAdvisoryLock<T>(key: string, fn: (tx: Queryable) => Promise<T>): Promise<T>;
|
|
27
|
+
/**
|
|
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.
|
|
31
|
+
*/
|
|
32
|
+
migrate(directory: string): Promise<string[]>;
|
|
33
|
+
close(): Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
/** What the platform holds for this service. See spec/registration.md, section 4. */
|
|
36
|
+
export interface PlatformSettings {
|
|
37
|
+
readonly slug: string;
|
|
38
|
+
readonly database?: {
|
|
39
|
+
readonly url: string;
|
|
40
|
+
readonly schema: string;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export interface DatabaseOptions {
|
|
44
|
+
/** The service's client. The database settings are fetched with it. */
|
|
45
|
+
readonly service?: Pick<ServiceClient, "call">;
|
|
46
|
+
/**
|
|
47
|
+
* Connect to this URL and do not ask the platform. For tests and for
|
|
48
|
+
* running a service on its own. Needs `slug`.
|
|
49
|
+
*/
|
|
50
|
+
readonly url?: string;
|
|
51
|
+
/** The service's slug. Only needed with `url`. */
|
|
52
|
+
readonly slug?: string;
|
|
53
|
+
/** The most connections to hold open. Default 10. */
|
|
54
|
+
readonly maxConnections?: number;
|
|
55
|
+
}
|
|
56
|
+
/** Ask the platform what it holds for this service. */
|
|
57
|
+
export declare function fetchSettings(service: Pick<ServiceClient, "call">): Promise<PlatformSettings>;
|
|
58
|
+
/**
|
|
59
|
+
* Connect to this service's database. With `service`, the platform is
|
|
60
|
+
* asked for the role and schema it made; the manifest must set
|
|
61
|
+
* permissions.db. Rejects if the database cannot be reached.
|
|
62
|
+
*/
|
|
63
|
+
export declare function connectDatabase(options: DatabaseOptions): Promise<Database>;
|
package/dist/db.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// PostgreSQL for a service: the role and schema the platform made for it.
|
|
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.
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { FinchlyError } from "./errors.js";
|
|
11
|
+
/** Ask the platform what it holds for this service. */
|
|
12
|
+
export async function fetchSettings(service) {
|
|
13
|
+
const response = await service.call("core", "/api/apps/self/config");
|
|
14
|
+
if (response.status !== 200) {
|
|
15
|
+
throw new FinchlyError("database_unavailable", "The platform did not give this service its settings", [
|
|
16
|
+
`GET /core/api/apps/self/config answered ${response.status}`,
|
|
17
|
+
]);
|
|
18
|
+
}
|
|
19
|
+
const body = (await response.json().catch(() => null));
|
|
20
|
+
if (body === null || typeof body !== "object" || typeof body.slug !== "string") {
|
|
21
|
+
throw new FinchlyError("database_unavailable", "The platform's settings answer could not be read");
|
|
22
|
+
}
|
|
23
|
+
return body;
|
|
24
|
+
}
|
|
25
|
+
async function loadPg() {
|
|
26
|
+
// A variable, so that TypeScript does not look for pg's types.
|
|
27
|
+
const name = "pg";
|
|
28
|
+
try {
|
|
29
|
+
const loaded = (await import(name));
|
|
30
|
+
return loaded.default ?? loaded;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
throw new FinchlyError("database_unavailable", "The db module needs the `pg` package", [
|
|
34
|
+
"Install it in the service: npm install pg",
|
|
35
|
+
]);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function toResult(result) {
|
|
39
|
+
// Several statements in one call give one result each. The last is the answer.
|
|
40
|
+
const last = (Array.isArray(result) ? result[result.length - 1] : result);
|
|
41
|
+
return { rows: last?.rows ?? [], rowCount: last?.rowCount ?? 0 };
|
|
42
|
+
}
|
|
43
|
+
function queryable(target) {
|
|
44
|
+
return {
|
|
45
|
+
async execute(sql, params) {
|
|
46
|
+
return toResult(params === undefined ? await target.query(sql) : await target.query(sql, [...params]));
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const MIGRATION_FILE = /^(\d{4})_([a-z0-9_]+)\.sql$/;
|
|
51
|
+
async function readMigrations(directory) {
|
|
52
|
+
let files;
|
|
53
|
+
try {
|
|
54
|
+
files = (await readdir(directory)).filter((file) => file.endsWith(".sql")).sort();
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
throw new FinchlyError("migration_invalid", "The migrations directory could not be read", [directory]);
|
|
58
|
+
}
|
|
59
|
+
const problems = [];
|
|
60
|
+
const migrations = [];
|
|
61
|
+
for (const file of files) {
|
|
62
|
+
const match = MIGRATION_FILE.exec(file);
|
|
63
|
+
if (!match) {
|
|
64
|
+
problems.push(`${file} is not named NNNN_name.sql`);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const sql = await readFile(join(directory, file), "utf8");
|
|
68
|
+
if (migrations.at(-1)?.version === match[1]) {
|
|
69
|
+
problems.push(`Two migrations share the version ${match[1]}`);
|
|
70
|
+
}
|
|
71
|
+
migrations.push({
|
|
72
|
+
version: match[1],
|
|
73
|
+
name: match[2],
|
|
74
|
+
sql,
|
|
75
|
+
checksum: createHash("sha256").update(sql).digest("hex"),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
if (problems.length > 0) {
|
|
79
|
+
throw new FinchlyError("migration_invalid", "The migrations cannot be applied", problems);
|
|
80
|
+
}
|
|
81
|
+
return migrations;
|
|
82
|
+
}
|
|
83
|
+
async function applyMigrations(tx, migrations) {
|
|
84
|
+
await tx.execute(`
|
|
85
|
+
CREATE TABLE IF NOT EXISTS platform_migrations (
|
|
86
|
+
version text PRIMARY KEY,
|
|
87
|
+
name text NOT NULL,
|
|
88
|
+
checksum text NOT NULL,
|
|
89
|
+
applied_at timestamptz NOT NULL DEFAULT now()
|
|
90
|
+
)
|
|
91
|
+
`);
|
|
92
|
+
const { rows } = await tx.execute("SELECT version, checksum FROM platform_migrations ORDER BY version");
|
|
93
|
+
const applied = new Map(rows.map((row) => [row.version, row.checksum]));
|
|
94
|
+
const known = new Set(migrations.map((migration) => migration.version));
|
|
95
|
+
const newestApplied = rows.at(-1)?.version ?? "";
|
|
96
|
+
const problems = [];
|
|
97
|
+
for (const version of applied.keys()) {
|
|
98
|
+
if (!known.has(version)) {
|
|
99
|
+
problems.push(`The database has migration ${version}, which this release of the service does not have`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
for (const migration of migrations) {
|
|
103
|
+
const checksum = applied.get(migration.version);
|
|
104
|
+
if (checksum !== undefined && checksum !== migration.checksum) {
|
|
105
|
+
problems.push(`${migration.version}_${migration.name} has changed since it was applied. Add a new migration instead`);
|
|
106
|
+
}
|
|
107
|
+
if (checksum === undefined && migration.version < newestApplied) {
|
|
108
|
+
problems.push(`${migration.version}_${migration.name} is older than ${newestApplied}, which is already applied`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (problems.length > 0) {
|
|
112
|
+
throw new FinchlyError("migration_invalid", "The migrations cannot be applied", problems);
|
|
113
|
+
}
|
|
114
|
+
const done = [];
|
|
115
|
+
for (const migration of migrations) {
|
|
116
|
+
if (applied.has(migration.version))
|
|
117
|
+
continue;
|
|
118
|
+
await tx.execute(migration.sql);
|
|
119
|
+
await tx.execute("INSERT INTO platform_migrations (version, name, checksum) VALUES ($1, $2, $3)", [
|
|
120
|
+
migration.version,
|
|
121
|
+
migration.name,
|
|
122
|
+
migration.checksum,
|
|
123
|
+
]);
|
|
124
|
+
done.push(migration.version);
|
|
125
|
+
}
|
|
126
|
+
return done;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Connect to this service's database. With `service`, the platform is
|
|
130
|
+
* asked for the role and schema it made; the manifest must set
|
|
131
|
+
* permissions.db. Rejects if the database cannot be reached.
|
|
132
|
+
*/
|
|
133
|
+
export async function connectDatabase(options) {
|
|
134
|
+
let url;
|
|
135
|
+
let slug;
|
|
136
|
+
let schema;
|
|
137
|
+
if (options.url !== undefined) {
|
|
138
|
+
if (!options.slug) {
|
|
139
|
+
throw new FinchlyError("database_unavailable", "connectDatabase needs `slug` when it is given `url`");
|
|
140
|
+
}
|
|
141
|
+
({ url, slug } = { url: options.url, slug: options.slug });
|
|
142
|
+
}
|
|
143
|
+
else if (options.service) {
|
|
144
|
+
const settings = await fetchSettings(options.service);
|
|
145
|
+
if (!settings.database) {
|
|
146
|
+
throw new FinchlyError("database_not_granted", "The platform made no database for this service", [
|
|
147
|
+
"Set permissions.db to true in the manifest",
|
|
148
|
+
]);
|
|
149
|
+
}
|
|
150
|
+
({ url, schema } = settings.database);
|
|
151
|
+
slug = settings.slug;
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
throw new FinchlyError("database_unavailable", "connectDatabase needs `service`, or `url` and `slug`");
|
|
155
|
+
}
|
|
156
|
+
const pg = await loadPg();
|
|
157
|
+
const pool = new pg.Pool({ connectionString: url, max: options.maxConnections ?? 10 });
|
|
158
|
+
// An idle connection that drops must not take the process down.
|
|
159
|
+
pool.on("error", () => { });
|
|
160
|
+
const onPool = queryable(pool);
|
|
161
|
+
try {
|
|
162
|
+
await pool.query("SELECT 1");
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
await pool.end().catch(() => { });
|
|
166
|
+
// pg's message names the host and the role, never the password.
|
|
167
|
+
throw new FinchlyError("database_unavailable", "The database could not be reached", [err.message]);
|
|
168
|
+
}
|
|
169
|
+
async function transaction(fn) {
|
|
170
|
+
const client = await pool.connect();
|
|
171
|
+
let broken;
|
|
172
|
+
try {
|
|
173
|
+
await client.query("BEGIN");
|
|
174
|
+
const result = await fn(queryable(client));
|
|
175
|
+
await client.query("COMMIT");
|
|
176
|
+
return result;
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
try {
|
|
180
|
+
await client.query("ROLLBACK");
|
|
181
|
+
}
|
|
182
|
+
catch (rollbackError) {
|
|
183
|
+
// The connection is in an unknown state. Do not put it back.
|
|
184
|
+
broken = rollbackError;
|
|
185
|
+
}
|
|
186
|
+
throw err;
|
|
187
|
+
}
|
|
188
|
+
finally {
|
|
189
|
+
client.release(broken);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
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);
|
|
205
|
+
// One transaction: instances starting together take turns, and a
|
|
206
|
+
// migration that fails leaves nothing behind.
|
|
207
|
+
return withAdvisoryLock("migrate", (tx) => applyMigrations(tx, migrations));
|
|
208
|
+
},
|
|
209
|
+
close: () => pool.end(),
|
|
210
|
+
};
|
|
211
|
+
}
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type FinchlyErrorCode = "config_invalid" | "manifest_invalid" | "manifest_unreadable" | "identity_invalid" | "gateway_unreachable" | "platform_api_unsupported" | "service_call_invalid" | "service_key_rejected" | "app_registration_refused" | "app_pending_approval";
|
|
1
|
+
export type FinchlyErrorCode = "config_invalid" | "manifest_invalid" | "manifest_unreadable" | "identity_invalid" | "gateway_unreachable" | "platform_api_unsupported" | "service_call_invalid" | "service_key_rejected" | "app_registration_refused" | "app_pending_approval" | "database_unavailable" | "database_not_granted" | "migration_invalid";
|
|
2
2
|
/**
|
|
3
3
|
* Every error this SDK throws on purpose. `code` is stable and safe to
|
|
4
4
|
* branch on; `problems` lists each thing that was wrong, so a caller can
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { DEFAULT_MANIFEST_PATH, loadConfig } from "./config.js";
|
|
2
2
|
export type { PlatformConfig } from "./config.js";
|
|
3
|
+
export { connectDatabase, fetchSettings } from "./db.js";
|
|
4
|
+
export type { Database, DatabaseOptions, PlatformSettings, QueryResult, Queryable } from "./db.js";
|
|
3
5
|
export { FinchlyError, IdentityError } from "./errors.js";
|
|
4
6
|
export type { FinchlyErrorCode, IdentityRejection } from "./errors.js";
|
|
5
7
|
export { IDENTITY_HEADER, createIdentityVerifier } from "./identity.js";
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { DEFAULT_MANIFEST_PATH, loadConfig } from "./config.js";
|
|
2
|
+
export { connectDatabase, fetchSettings } from "./db.js";
|
|
2
3
|
export { FinchlyError, IdentityError } from "./errors.js";
|
|
3
4
|
export { IDENTITY_HEADER, createIdentityVerifier } from "./identity.js";
|
|
4
5
|
export { checkManifest, loadManifest } from "./manifest.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@firetail/finchly",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.6",
|
|
4
4
|
"description": "Node SDK for the Finchly platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"scripts": {
|
|
23
23
|
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
24
24
|
"build": "npm run clean && tsc -p tsconfig.json",
|
|
25
|
-
"test": "npm run build && node --test \"test/**/*.test.mjs\"",
|
|
25
|
+
"test": "npm run build && node test/support/with-postgres.mjs node --test \"test/**/*.test.mjs\"",
|
|
26
26
|
"prepublishOnly": "npm test"
|
|
27
27
|
},
|
|
28
28
|
"repository": {
|
|
@@ -34,9 +34,18 @@
|
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"@types/node": "^22.0.0",
|
|
37
|
-
"typescript": "~5.9.3"
|
|
37
|
+
"typescript": "~5.9.3",
|
|
38
|
+
"pg": "^8.11.0"
|
|
38
39
|
},
|
|
39
40
|
"dependencies": {
|
|
40
41
|
"jose": "^6.2.17"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"pg": "^8.11.0"
|
|
45
|
+
},
|
|
46
|
+
"peerDependenciesMeta": {
|
|
47
|
+
"pg": {
|
|
48
|
+
"optional": true
|
|
49
|
+
}
|
|
41
50
|
}
|
|
42
51
|
}
|