@segmentstream-cloud/ontology 0.24.0 → 0.24.1

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/README.md CHANGED
@@ -500,6 +500,22 @@ usage error.
500
500
  `ontology/index.ts`, migrates the workspace database and deploys the service under
501
501
  `/api/ontology`. A failure stops the deployment and keeps the running version.
502
502
 
503
+ ## Development tests
504
+
505
+ `npm test --workspace @segmentstream-cloud/ontology` runs the ordinary suite with
506
+ embedded PGlite. Authentication contention tests require separate PostgreSQL
507
+ backends and run against a local disposable PostgreSQL server:
508
+
509
+ ```sh
510
+ ONTOLOGY_TEST_ADMIN_URL=postgresql://postgres@127.0.0.1:5432/postgres \
511
+ npm run test:postgres --workspace @segmentstream-cloud/ontology
512
+ ```
513
+
514
+ The test fixture accepts only a localhost `postgres` admin connection to the
515
+ `postgres` database. It creates a randomly named test database, applies the example
516
+ ontology, and drops that database on completion. Repository CI and the SDK release
517
+ gate run this suite with PostgreSQL 17.
518
+
503
519
  ## License
504
520
 
505
521
  Apache-2.0
@@ -46,7 +46,8 @@ export declare const SIGN_IN_RECORD_INTERVAL_MS: number;
46
46
  * is linked by email when someone invited them; a person nobody invited is rejected as
47
47
  * `not_invited` under the `invited` policy and becomes a member holding only `member` under
48
48
  * `open`. Every sign-in that lets the member in sets `lastSignInAt`, at most once per
49
- * {@link SIGN_IN_RECORD_INTERVAL_MS}, so history keeps it. Inactive users are rejected.
49
+ * {@link SIGN_IN_RECORD_INTERVAL_MS}, so history keeps it. If the User row is busy, recording is
50
+ * deferred to a later request instead of delaying authentication. Inactive users are rejected.
50
51
  */
51
52
  export declare function resolvePrincipalByIdentity(ontology: Ontology, claims: IdentityClaims): Effect.Effect<Principal, PrincipalRejected, SqlClient.SqlClient>;
52
53
  /** Resolve the workspace's stable keyless pipeline service, without customer roles or sign-in. */
@@ -15,7 +15,7 @@ import { beginReplicationWrite } from "../replication/transaction.js";
15
15
  import { User } from "../user.js";
16
16
  import { MemberNotFound, PrincipalRejected, UnknownRole } from "./errors.js";
17
17
  import { isRecordId } from "./records.js";
18
- import { makeOntologyRuntime } from "./sql-runtime.js";
18
+ import { makeOntologyRuntime, makePrincipalUserWriter, } from "./sql-runtime.js";
19
19
  /** API keys are `ssk_<key id>.<secret>`. */
20
20
  export const API_KEY_PREFIX = "ssk_";
21
21
  /**
@@ -107,93 +107,116 @@ function userRow(record) {
107
107
  return decodeUser({ id: record.id, kind: record.kind, active: record.active });
108
108
  }
109
109
  /**
110
- * Finds, links or creates the member's user, in one transaction, and records the sign-in on it. A
110
+ * Finds the member's user without locking. Links or creates it transactionally when needed. A
111
111
  * user linked to this identity is found; else a member invited with the same email is linked; else
112
112
  * the ontology's sign-in policy decides: `invited` turns the person away, `open` creates a member.
113
113
  */
114
114
  function signIn(ontology, sql, claims) {
115
- const runtime = platformRuntime(ontology, sql, "user.sign_in");
115
+ const runtime = makeOntologyRuntime(ontology, sql);
116
+ const writer = makePrincipalUserWriter(ontology, sql, {
117
+ action: "user.sign_in",
118
+ actionId: randomUUID(),
119
+ });
116
120
  const claimedEmail = claims.email?.trim().toLowerCase();
117
121
  const email = claimedEmail !== undefined && EMAIL_PATTERN.test(claimedEmail) ? claimedEmail : undefined;
118
- return sql
119
- .withTransaction(Effect.gen(function* () {
120
- yield* beginReplicationWrite(sql);
121
- const now = new Date();
122
- const signedIn = { last_sign_in_at: now.toISOString() };
123
- const [linked] = yield* runtime.find({
124
- entity: "user",
125
- options: {
126
- where: {
127
- identity_provider: claims.identityProvider,
128
- identity_subject: claims.identitySubject,
129
- },
130
- limit: 1,
131
- forUpdate: true,
122
+ const findLinked = (forUpdate = false) => runtime.find({
123
+ entity: "user",
124
+ options: {
125
+ where: {
126
+ identity_provider: claims.identityProvider,
127
+ identity_subject: claims.identitySubject,
132
128
  },
133
- });
134
- if (linked !== undefined) {
135
- // An inactive user is turned away, which is not a sign-in to record.
136
- if (linked.active !== true || !signInDue(linked.last_sign_in_at, now)) {
137
- return userRow(linked);
129
+ limit: 1,
130
+ forUpdate,
131
+ },
132
+ });
133
+ const member = (record) => record.kind === "member"
134
+ ? Effect.succeed(userRow(record))
135
+ : Effect.fail(new PrincipalRejected({
136
+ reason: "identity_conflict",
137
+ message: "This identity belongs to a service, not a member.",
138
+ }));
139
+ return Effect.gen(function* () {
140
+ const [known] = yield* findLinked();
141
+ if (known !== undefined) {
142
+ const user = yield* member(known);
143
+ // Ordinary authentication is read-only. Optional bookkeeping must never queue behind a
144
+ // business transaction holding a User key or row lock; a later request can record it.
145
+ if (!user.active || !writer.recordsSignIn || !signInDue(known.last_sign_in_at, new Date())) {
146
+ return user;
138
147
  }
139
- const returning = yield* runtime.update({
140
- entity: "user",
141
- id: linked.id,
142
- values: signedIn,
143
- });
144
- return userRow(returning);
148
+ return yield* sql.withTransaction(Effect.gen(function* () {
149
+ const [locked] = yield* sql.unsafe(`SELECT "id" FROM "${USER_TABLE}" WHERE "id" = $1::uuid FOR UPDATE SKIP LOCKED`, [user.id]);
150
+ if (locked === undefined) {
151
+ return user;
152
+ }
153
+ const current = yield* runtime.get({ entity: "user", id: user.id });
154
+ const principal = yield* member(current);
155
+ const now = new Date();
156
+ if (principal.active && signInDue(current.last_sign_in_at, now)) {
157
+ yield* writer.update(user.id, { last_sign_in_at: now.toISOString() });
158
+ }
159
+ return principal;
160
+ }));
145
161
  }
146
- const [invited] = email === undefined
147
- ? []
148
- : yield* runtime.find({
149
- entity: "user",
150
- options: { where: { email }, limit: 1, forUpdate: true },
151
- });
152
- if (invited !== undefined) {
153
- if (invited.kind !== "member" || invited.identity_subject !== null) {
154
- return yield* new PrincipalRejected({
155
- reason: "identity_conflict",
156
- message: "Another user already has this email.",
157
- });
162
+ return yield* sql.withTransaction(Effect.gen(function* () {
163
+ const now = new Date();
164
+ const signedIn = writer.recordsSignIn ? { last_sign_in_at: now.toISOString() } : {};
165
+ const [linked] = yield* findLinked(true);
166
+ if (linked !== undefined) {
167
+ return yield* member(linked);
158
168
  }
159
- const linkedUser = yield* runtime.update({
160
- entity: "user",
161
- id: invited.id,
162
- values: {
169
+ const [invited] = email === undefined
170
+ ? []
171
+ : yield* runtime.find({
172
+ entity: "user",
173
+ options: { where: { email }, limit: 1, forUpdate: true },
174
+ });
175
+ if (invited !== undefined) {
176
+ // A concurrent first sign-in may have linked this invitation while this transaction
177
+ // waited for its row. Accept the exact identity; another identity still conflicts.
178
+ if (invited.identity_provider === claims.identityProvider &&
179
+ invited.identity_subject === claims.identitySubject) {
180
+ return yield* member(invited);
181
+ }
182
+ if (invited.kind !== "member" || invited.identity_subject !== null) {
183
+ return yield* new PrincipalRejected({
184
+ reason: "identity_conflict",
185
+ message: "Another user already has this email.",
186
+ });
187
+ }
188
+ const linkedUser = yield* writer.update(invited.id, {
163
189
  identity_provider: claims.identityProvider,
164
190
  identity_subject: claims.identitySubject,
165
191
  ...(invited.active === true ? signedIn : {}),
166
- },
167
- });
168
- return userRow(linkedUser);
169
- }
170
- if (ontology.signIn.policy !== "open") {
171
- return yield* new PrincipalRejected({
172
- reason: "not_invited",
173
- message: "No member was invited with this identity or email.",
174
- });
175
- }
176
- const created = yield* runtime.create({
177
- entity: "user",
178
- values: {
192
+ });
193
+ return userRow(linkedUser);
194
+ }
195
+ if (ontology.signIn.policy !== "open") {
196
+ return yield* new PrincipalRejected({
197
+ reason: "not_invited",
198
+ message: "No member was invited with this identity or email.",
199
+ });
200
+ }
201
+ const created = yield* writer.create({
179
202
  name: claims.name?.trim() || email || claims.identitySubject,
180
203
  email: email ?? null,
181
204
  kind: "member",
182
205
  identity_provider: claims.identityProvider,
183
206
  identity_subject: claims.identitySubject,
184
207
  ...signedIn,
185
- },
186
- });
187
- return userRow(created);
188
- }))
189
- .pipe(Effect.catchTag("NotFound", (error) => Effect.die(error)));
208
+ });
209
+ return userRow(created);
210
+ }));
211
+ }).pipe(Effect.catchTag("NotFound", (error) => Effect.die(error)));
190
212
  }
191
213
  /**
192
214
  * The principal of a member signed in with a verified token. On first sign-in the member's `user`
193
215
  * is linked by email when someone invited them; a person nobody invited is rejected as
194
216
  * `not_invited` under the `invited` policy and becomes a member holding only `member` under
195
217
  * `open`. Every sign-in that lets the member in sets `lastSignInAt`, at most once per
196
- * {@link SIGN_IN_RECORD_INTERVAL_MS}, so history keeps it. Inactive users are rejected.
218
+ * {@link SIGN_IN_RECORD_INTERVAL_MS}, so history keeps it. If the User row is busy, recording is
219
+ * deferred to a later request instead of delaying authentication. Inactive users are rejected.
197
220
  */
198
221
  export function resolvePrincipalByIdentity(ontology, claims) {
199
222
  return Effect.gen(function* () {
@@ -1,9 +1,9 @@
1
1
  import { Effect } from "effect";
2
2
  import type { SqlClient } from "effect/unstable/sql";
3
- import { type NotFound } from "../errors.js";
3
+ import { Conflict, type NotFound } from "../errors.js";
4
4
  import type { Ontology } from "../ontology.js";
5
5
  import type { Query, QueryResult } from "../query.js";
6
- import type { OntologyRuntimeService, StoredRecord } from "../runtime.js";
6
+ import type { OntologyRuntimeService, StoredRecord, StoredValues } from "../runtime.js";
7
7
  import { InvalidQuery } from "./errors.js";
8
8
  import type { Edit, HistoryEntry, HistoryScope } from "./history.js";
9
9
  /**
@@ -39,6 +39,16 @@ export interface SqlOntologyRuntime extends OntologyRuntimeService {
39
39
  readonly limit?: number;
40
40
  }) => Effect.Effect<QueryResult<HistoryEntry>, InvalidQuery>;
41
41
  }
42
+ /**
43
+ * Platform identity bookkeeping touches only User properties and their history. User is excluded
44
+ * from business replication; keep this internal capability separate from the general runtime so
45
+ * deleting users, cascading relationships or editing business records still takes its fence.
46
+ */
47
+ export declare function makePrincipalUserWriter(ontology: Ontology, sql: SqlClient.SqlClient, scope: Pick<HistoryScope, "action" | "actionId">): {
48
+ recordsSignIn: boolean;
49
+ create: (values: StoredValues) => Effect.Effect<Readonly<Record<string, unknown>>, Conflict | NotFound, never>;
50
+ update: (id: string, values: StoredValues) => Effect.Effect<Readonly<Record<string, unknown>>, Conflict | NotFound, never>;
51
+ };
42
52
  /**
43
53
  * The runtime over one SQL client, normally `@effect/sql-pg`'s. Statements are parameterised; the
44
54
  * client runs them on the transaction of the calling fiber, so a runtime made for an action shares
@@ -2,6 +2,8 @@ import { Effect } from "effect";
2
2
  import { beginReplicationWrite } from "../replication/transaction.js";
3
3
  import { isSqlError } from "effect/unstable/sql/SqlError";
4
4
  import { Conflict } from "../errors.js";
5
+ import { toSnakeCase } from "../names.js";
6
+ import { User } from "../user.js";
5
7
  import { InvalidQuery, WriteOutsideAction } from "./errors.js";
6
8
  import { entityModel, ontologyModel } from "./model.js";
7
9
  import { loadRecord, notFound, readHistory, relatedCondition, runQuery, validateQuery, } from "./reads.js";
@@ -41,6 +43,37 @@ function uniqueConflict(entity, error) {
41
43
  message: `A ${label} with this ${field.name} already exists.`,
42
44
  }));
43
45
  }
46
+ /**
47
+ * Platform identity bookkeeping touches only User properties and their history. User is excluded
48
+ * from business replication; keep this internal capability separate from the general runtime so
49
+ * deleting users, cascading relationships or editing business records still takes its fence.
50
+ */
51
+ export function makePrincipalUserWriter(ontology, sql, scope) {
52
+ const model = ontologyModel(ontology);
53
+ const user = entityModel(model, User.name);
54
+ const allowed = new Set(Object.keys(User.definition.properties).map(toSnakeCase));
55
+ const client = sql.withoutTransforms();
56
+ const context = () => ({
57
+ model,
58
+ scope: { action: scope.action, actionId: scope.actionId, principalId: null },
59
+ edits: [],
60
+ run: (text, params) => client.unsafe(text, params.values),
61
+ });
62
+ const write = (values, operation) => Effect.suspend(() => {
63
+ if (!user.manifest.builtIn ||
64
+ Object.keys(values).some((name) => !allowed.has(name) || user.fieldsByName.get(name)?.source !== "property")) {
65
+ return Effect.die(new Error("Principal writes accept only built-in User properties."));
66
+ }
67
+ return sql
68
+ .withTransaction(operation())
69
+ .pipe(Effect.catchIf(isSqlError, (error) => uniqueConflict(user, error)));
70
+ });
71
+ return {
72
+ recordsSignIn: user.fieldsByName.has("last_sign_in_at"),
73
+ create: (values) => write(values, () => createRecord(context(), user, values)),
74
+ update: (id, values) => write(values, () => updateRecord(context(), user, id, values)),
75
+ };
76
+ }
44
77
  /**
45
78
  * The runtime over one SQL client, normally `@effect/sql-pg`'s. Statements are parameterised; the
46
79
  * client runs them on the transaction of the calling fiber, so a runtime made for an action shares
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@segmentstream-cloud/ontology",
3
- "version": "0.24.0",
3
+ "version": "0.24.1",
4
4
  "description": "Define a SegmentStream workspace app's ontology: entities, relationships, roles and actions, written with Effect.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "SegmentStream",
@@ -44,6 +44,7 @@
44
44
  "typecheck": "tsc -p tsconfig.json --noEmit",
45
45
  "lint": "eslint src tests --config ../../eslint.config.mjs",
46
46
  "test": "vitest run",
47
+ "test:postgres": "vitest run --config vitest.postgres.config.ts",
47
48
  "prepack": "npm run build"
48
49
  },
49
50
  "dependencies": {