@segmentstream-cloud/ontology 0.24.0 → 0.25.0
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 +16 -0
- package/dist/migrate/apply.js +34 -1
- package/dist/replication/baseline.d.ts +18 -4
- package/dist/replication/baseline.js +153 -62
- package/dist/replication/legacy-baseline.d.ts +27 -0
- package/dist/replication/legacy-baseline.js +106 -0
- package/dist/replication/legacy-store.d.ts +23 -0
- package/dist/replication/legacy-store.js +61 -0
- package/dist/replication/schema.d.ts +1 -1
- package/dist/replication/schema.js +51 -5
- package/dist/replication/store.d.ts +14 -11
- package/dist/replication/store.js +251 -62
- package/dist/replication/transaction.d.ts +4 -0
- package/dist/replication/transaction.js +23 -4
- package/dist/runtime/action-transactions.d.ts +35 -0
- package/dist/runtime/action-transactions.js +54 -0
- package/dist/runtime/pipeline.js +17 -3
- package/dist/runtime/principals.d.ts +2 -1
- package/dist/runtime/principals.js +86 -63
- package/dist/runtime/service-binding.js +4 -1
- package/dist/runtime/sql-runtime.d.ts +14 -2
- package/dist/runtime/sql-runtime.js +37 -2
- package/dist/service/replication.d.ts +1 -1
- package/dist/service/replication.js +27 -16
- package/package.json +2 -1
package/dist/runtime/pipeline.js
CHANGED
|
@@ -16,6 +16,7 @@ import { Edit } from "./history.js";
|
|
|
16
16
|
import { makeOntologyRuntime } from "./sql-runtime.js";
|
|
17
17
|
import { AgentFileRecords, resolveFileRecord } from "./files.js";
|
|
18
18
|
import { validate } from "./validation.js";
|
|
19
|
+
import { ACTION_TRANSACTION_LIMITS, ActionTransactionDeadline, ActionTransactions, } from "./action-transactions.js";
|
|
19
20
|
/** How long `prepare` may take, connection calls included; each call has its own 30 s deadline. */
|
|
20
21
|
export const PREPARE_TIMEOUT = Duration.seconds(60);
|
|
21
22
|
export const ActionValidation = Schema.Struct({
|
|
@@ -173,6 +174,7 @@ export function runAction(ontology, request) {
|
|
|
173
174
|
...(request.branchId ? { branchId: request.branchId } : {}),
|
|
174
175
|
...(request.options?.createIntent ? { createIntent: request.options.createIntent } : {}),
|
|
175
176
|
onEdit: (edit) => edits.push(edit),
|
|
177
|
+
replicationInitialized: true,
|
|
176
178
|
});
|
|
177
179
|
const handle = action.handler;
|
|
178
180
|
const handler = Effect.suspend(() => handle({ ...context, prepared }));
|
|
@@ -209,9 +211,21 @@ export function runAction(ontology, request) {
|
|
|
209
211
|
}
|
|
210
212
|
return result;
|
|
211
213
|
});
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
214
|
+
const admission = Option.getOrUndefined(yield* Effect.serviceOption(ActionTransactions));
|
|
215
|
+
const limits = admission?.limits ?? ACTION_TRANSACTION_LIMITS;
|
|
216
|
+
const transactionBody = Effect.gen(function* () {
|
|
217
|
+
// Server deadlines also protect the reserved connection if client cancellation cannot arrive.
|
|
218
|
+
yield* sql.unsafe(`SELECT set_config('statement_timeout', $1, true), set_config('lock_timeout', $2, true)`, [`${limits.statementTimeoutMs}ms`, `${limits.lockTimeoutMs}ms`]);
|
|
219
|
+
yield* beginReplicationWrite(sql);
|
|
220
|
+
return yield* transactionalHandler;
|
|
221
|
+
}).pipe(Effect.timeoutOrElse({
|
|
222
|
+
duration: Duration.millis(limits.handlerTimeoutMs),
|
|
223
|
+
orElse: () => Effect.logWarning("ontology.action.transaction_deadline").pipe(Effect.annotateLogs({ deadlineMs: limits.handlerTimeoutMs }), Effect.andThen(Effect.die(new ActionTransactionDeadline(`${qualified} exceeded its transaction deadline.`)))),
|
|
224
|
+
}));
|
|
225
|
+
// Time out the interruptible body only. The SQL manager must finish rollback/commit and
|
|
226
|
+
// release its connection; a lost COMMIT response must never cause an automatic replay.
|
|
227
|
+
const transaction = sql.withTransaction(transactionBody);
|
|
228
|
+
const result = yield* (admission ? admission.admit(transaction) : transaction).pipe(Effect.annotateLogs({ action: qualified, actionId, requestId }), Effect.catchIf(isSqlError, (error) => Effect.die(error)));
|
|
215
229
|
if (replay) {
|
|
216
230
|
return replay;
|
|
217
231
|
}
|
|
@@ -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.
|
|
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
|
|
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 =
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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.
|
|
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* () {
|
|
@@ -14,6 +14,7 @@ import { runAction } from "./pipeline.js";
|
|
|
14
14
|
import { checkSignIn, resolvePrincipalByApiKey, resolvePrincipalByIdentity, resolvePipelineWorker, } from "./principals.js";
|
|
15
15
|
import { postgresTls } from "./postgres-tls.js";
|
|
16
16
|
import { makeOntologyRuntime } from "./sql-runtime.js";
|
|
17
|
+
import { ActionTransactions, makeActionTransactions } from "./action-transactions.js";
|
|
17
18
|
/** Connections one service instance holds to the database's pooler. */
|
|
18
19
|
const MAX_CONNECTIONS = 5;
|
|
19
20
|
/**
|
|
@@ -28,6 +29,7 @@ export function backendLayer(ontology, blobStorage) {
|
|
|
28
29
|
return Layer.effect(OntologyBackend, Effect.gen(function* () {
|
|
29
30
|
const sql = yield* SqlClient.SqlClient;
|
|
30
31
|
const runtime = makeOntologyRuntime(ontology, sql);
|
|
32
|
+
const actionTransactions = makeActionTransactions();
|
|
31
33
|
const withSql = (effect) => Effect.provideService(effect, SqlClient.SqlClient, sql);
|
|
32
34
|
const binding = blobStorage ? Redacted.value(blobStorage) : undefined;
|
|
33
35
|
const provider = binding
|
|
@@ -49,7 +51,7 @@ export function backendLayer(ontology, blobStorage) {
|
|
|
49
51
|
afterCommit: input.afterCommit === undefined ? [] : [input.afterCommit],
|
|
50
52
|
...(input.requestId === undefined ? {} : { requestId: input.requestId }),
|
|
51
53
|
...(input.connections === undefined ? {} : { connections: input.connections }),
|
|
52
|
-
})).pipe(Effect.catchTag("UnknownAction", (error) => Effect.die(error))),
|
|
54
|
+
})).pipe(Effect.provideService(ActionTransactions, actionTransactions), Effect.catchTag("UnknownAction", (error) => Effect.die(error))),
|
|
53
55
|
get: (input) => runtime.get(input),
|
|
54
56
|
find: (input) => runtime.query(input),
|
|
55
57
|
history: (input) => runtime.history(input),
|
|
@@ -87,6 +89,7 @@ export function postgresLayer(databaseUrl) {
|
|
|
87
89
|
url: databaseUrl,
|
|
88
90
|
ssl: postgresTls(databaseUrl),
|
|
89
91
|
maxConnections: MAX_CONNECTIONS,
|
|
92
|
+
connectTimeout: "5 seconds",
|
|
90
93
|
applicationName: "segmentstream-ontology",
|
|
91
94
|
});
|
|
92
95
|
}
|
|
@@ -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
|
/**
|
|
@@ -11,6 +11,8 @@ import type { Edit, HistoryEntry, HistoryScope } from "./history.js";
|
|
|
11
11
|
* `created_by`/`updated_by` to its principal.
|
|
12
12
|
*/
|
|
13
13
|
export interface WriteScope extends HistoryScope {
|
|
14
|
+
/** Set only when the enclosing action initialized replication in its outer transaction. */
|
|
15
|
+
readonly replicationInitialized?: boolean;
|
|
14
16
|
/** Receives the edits of each write that succeeded, in order. */
|
|
15
17
|
readonly onEdit?: (edit: Edit) => void;
|
|
16
18
|
}
|
|
@@ -39,6 +41,16 @@ export interface SqlOntologyRuntime extends OntologyRuntimeService {
|
|
|
39
41
|
readonly limit?: number;
|
|
40
42
|
}) => Effect.Effect<QueryResult<HistoryEntry>, InvalidQuery>;
|
|
41
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* Platform identity bookkeeping touches only User properties and their history. User is excluded
|
|
46
|
+
* from business replication; keep this internal capability separate from the general runtime so
|
|
47
|
+
* deleting users, cascading relationships or editing business records still takes its fence.
|
|
48
|
+
*/
|
|
49
|
+
export declare function makePrincipalUserWriter(ontology: Ontology, sql: SqlClient.SqlClient, scope: Pick<HistoryScope, "action" | "actionId">): {
|
|
50
|
+
recordsSignIn: boolean;
|
|
51
|
+
create: (values: StoredValues) => Effect.Effect<Readonly<Record<string, unknown>>, Conflict | NotFound, never>;
|
|
52
|
+
update: (id: string, values: StoredValues) => Effect.Effect<Readonly<Record<string, unknown>>, Conflict | NotFound, never>;
|
|
53
|
+
};
|
|
42
54
|
/**
|
|
43
55
|
* The runtime over one SQL client, normally `@effect/sql-pg`'s. Statements are parameterised; the
|
|
44
56
|
* client runs them on the transaction of the calling fiber, so a runtime made for an action shares
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { Effect } from "effect";
|
|
1
|
+
import { Effect, Option } 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
|
|
@@ -67,8 +100,10 @@ export function makeOntologyRuntime(ontology, sql, scope) {
|
|
|
67
100
|
}));
|
|
68
101
|
}
|
|
69
102
|
const edits = [];
|
|
103
|
+
const enclosingTransaction = yield* Effect.serviceOption(sql.transactionService);
|
|
104
|
+
const initialized = scope.replicationInitialized && Option.isSome(enclosingTransaction);
|
|
70
105
|
const result = yield* sql
|
|
71
|
-
.withTransaction(beginReplicationWrite(sql).pipe(Effect.andThen(operation({ ...context, scope, edits }, entity))))
|
|
106
|
+
.withTransaction((initialized ? Effect.void : beginReplicationWrite(sql)).pipe(Effect.andThen(operation({ ...context, scope, edits }, entity))))
|
|
72
107
|
.pipe(Effect.catchIf(isSqlError, (error) => uniqueConflict(entity, error)));
|
|
73
108
|
for (const edit of edits) {
|
|
74
109
|
scope.onEdit?.(edit);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Effect } from "effect";
|
|
2
2
|
import type { OntologyEnvironment } from "./config.js";
|
|
3
3
|
export type ReplicationKick = (identity: string | undefined) => Effect.Effect<void>;
|
|
4
|
-
/**
|
|
4
|
+
/** Coalesce best-effort wakes without adding network latency to committed actions. Durable recovery owns retries. */
|
|
5
5
|
export declare function deployedReplication(environment: OntologyEnvironment, fetchImpl?: typeof fetch): ReplicationKick;
|
|
@@ -1,25 +1,36 @@
|
|
|
1
1
|
import { Effect } from "effect";
|
|
2
|
-
/**
|
|
2
|
+
/** Coalesce best-effort wakes without adding network latency to committed actions. Durable recovery owns retries. */
|
|
3
3
|
export function deployedReplication(environment, fetchImpl = fetch) {
|
|
4
|
+
let inFlight = false;
|
|
5
|
+
let nextWakeAt = 0;
|
|
4
6
|
return (identity) => {
|
|
5
7
|
if (!identity) {
|
|
6
8
|
return Effect.void;
|
|
7
9
|
}
|
|
8
|
-
return Effect.
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
method: "POST",
|
|
12
|
-
redirect: "error",
|
|
13
|
-
signal: AbortSignal.timeout(5000),
|
|
14
|
-
headers: {
|
|
15
|
-
"x-segmentstream-runtime-identity": identity,
|
|
16
|
-
"x-segmentstream-organization-id": environment.organizationId,
|
|
17
|
-
},
|
|
18
|
-
});
|
|
19
|
-
await response.body?.cancel();
|
|
20
|
-
if (!response.ok) {
|
|
21
|
-
throw new Error(`Replication wake-up returned HTTP ${response.status}.`);
|
|
10
|
+
return Effect.suspend(() => {
|
|
11
|
+
if (inFlight || Date.now() < nextWakeAt) {
|
|
12
|
+
return Effect.void;
|
|
22
13
|
}
|
|
23
|
-
|
|
14
|
+
inFlight = true;
|
|
15
|
+
nextWakeAt = Date.now() + 1000;
|
|
16
|
+
return Effect.tryPromise(async () => {
|
|
17
|
+
const response = await fetchImpl(`${environment.segmentstreamUrl}/api/internal/v1/workspaces/` +
|
|
18
|
+
`${encodeURIComponent(environment.workspaceId)}/ontology/replication/drain`, {
|
|
19
|
+
method: "POST",
|
|
20
|
+
redirect: "error",
|
|
21
|
+
signal: AbortSignal.timeout(5000),
|
|
22
|
+
headers: {
|
|
23
|
+
"x-segmentstream-runtime-identity": identity,
|
|
24
|
+
"x-segmentstream-organization-id": environment.organizationId,
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
await response.body?.cancel();
|
|
28
|
+
if (!response.ok) {
|
|
29
|
+
throw new Error(`Replication wake-up returned HTTP ${response.status}.`);
|
|
30
|
+
}
|
|
31
|
+
}).pipe(Effect.catchCause((cause) => Effect.logWarning("ontology.replication.deferred", cause)), Effect.ensuring(Effect.sync(() => {
|
|
32
|
+
inFlight = false;
|
|
33
|
+
})), Effect.forkDetach, Effect.asVoid);
|
|
34
|
+
});
|
|
24
35
|
};
|
|
25
36
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@segmentstream-cloud/ontology",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
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": {
|