@microck/canonfig 2.0.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.
Files changed (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +263 -0
  3. package/dist/agent/agent-resolution.errors.js +42 -0
  4. package/dist/agent/agent-resolution.layer.js +204 -0
  5. package/dist/agent/agent-resolution.service.js +2259 -0
  6. package/dist/agent/agent-resolution.types.js +1 -0
  7. package/dist/agent/controlled-executor.js +704 -0
  8. package/dist/agent/harness-adapters.js +85 -0
  9. package/dist/cli/cli.js +618 -0
  10. package/dist/cli/exit-codes.js +28 -0
  11. package/dist/cli/follower-commands.js +3 -0
  12. package/dist/cli/render.js +56 -0
  13. package/dist/cli/source-commands.js +5 -0
  14. package/dist/domain/brand.js +29 -0
  15. package/dist/domain/identity.js +31 -0
  16. package/dist/domain/npm-package-spec.js +186 -0
  17. package/dist/domain/profile.js +950 -0
  18. package/dist/domain/recipe-versions.js +297 -0
  19. package/dist/domain/resource.js +259 -0
  20. package/dist/domain/synchronization.js +346 -0
  21. package/dist/enrollment/enrollment.errors.js +43 -0
  22. package/dist/enrollment/enrollment.layer.js +724 -0
  23. package/dist/enrollment/enrollment.service.js +3 -0
  24. package/dist/enrollment/enrollment.types.js +59 -0
  25. package/dist/enrollment/follower-client.js +585 -0
  26. package/dist/enrollment/source-server.js +313 -0
  27. package/dist/machine/linux.layer.js +1183 -0
  28. package/dist/machine/machine-state.errors.js +52 -0
  29. package/dist/machine/machine-state.service.js +3 -0
  30. package/dist/machine/machine-state.types.js +1 -0
  31. package/dist/machine/macos.layer.js +470 -0
  32. package/dist/machine/windows.layer.js +879 -0
  33. package/dist/profile/discovery.js +740 -0
  34. package/dist/profile/profile-catalog.errors.js +50 -0
  35. package/dist/profile/profile-catalog.layer.js +20 -0
  36. package/dist/profile/profile-catalog.service.js +7 -0
  37. package/dist/profile/profile-codec.js +153 -0
  38. package/dist/profile/publication.js +298 -0
  39. package/dist/profile/tool-catalog.js +384 -0
  40. package/dist/runtime/doctor.js +306 -0
  41. package/dist/runtime/layers.js +706 -0
  42. package/dist/runtime/main.js +38 -0
  43. package/dist/schedule/linux-schedule.js +24 -0
  44. package/dist/schedule/macos-schedule.js +25 -0
  45. package/dist/schedule/schedule-manager.errors.js +17 -0
  46. package/dist/schedule/schedule-manager.layer.js +205 -0
  47. package/dist/schedule/schedule-manager.service.js +3 -0
  48. package/dist/schedule/schedule-manager.types.js +114 -0
  49. package/dist/schedule/windows-schedule.js +25 -0
  50. package/dist/state/state-repository.errors.js +55 -0
  51. package/dist/state/state-repository.layer.js +1507 -0
  52. package/dist/state/state-repository.service.js +3 -0
  53. package/dist/state/state-repository.types.js +1 -0
  54. package/dist/state/state-schema.js +298 -0
  55. package/dist/synchronization/config-codec.js +97 -0
  56. package/dist/synchronization/executor.js +700 -0
  57. package/dist/synchronization/follower-orchestration.js +939 -0
  58. package/dist/synchronization/follower-sync-config.js +81 -0
  59. package/dist/synchronization/npm-artifact.js +670 -0
  60. package/dist/synchronization/planner.js +378 -0
  61. package/dist/synchronization/recovery.js +397 -0
  62. package/dist/synchronization/resource-executors.js +1198 -0
  63. package/dist/synchronization/resource-plans.js +645 -0
  64. package/dist/synchronization/synchronization.errors.js +102 -0
  65. package/dist/synchronization/synchronization.layer.js +97 -0
  66. package/dist/synchronization/synchronization.service.js +11 -0
  67. package/dist/synchronization/synchronization.types.js +1 -0
  68. package/package.json +66 -0
@@ -0,0 +1,1507 @@
1
+ import { SqliteClient, SqliteMigrator } from "@effect/sql-sqlite-node";
2
+ import { Effect, Layer, Schema } from "effect";
3
+ import { SqlClient } from "effect/unstable/sql";
4
+ import { ActionId, BlobId, CertificateFingerprint, ContentDigest, CredentialReference, FollowerId, GroupName, ProfileRevisionId, ResourceId, RunId, } from "../domain/brand.js";
5
+ import { FollowerIdentity, SourceIdentity } from "../domain/identity.js";
6
+ import { ProfileRevisionSchema } from "../domain/profile.js";
7
+ import { AppliedResourceRecordSchema, DriftConflictSchema, SynchronizationPlanSchema, } from "../domain/synchronization.js";
8
+ import { FollowerSynchronizationConfiguration, LocalOverlayEntrySchema, } from "../synchronization/follower-sync-config.js";
9
+ import { SyncScheduleSchema } from "../schedule/schedule-manager.types.js";
10
+ import { ActionNotInPlanError, ActiveRunExistsError, EnrollmentStateConflictError, FollowerNotFoundError, InvalidRunTransitionError, RepositoryDecodeError, RepositorySqlError, RevisionImmutableError, RevisionNotFoundError, RunNotFoundError, } from "./state-repository.errors.js";
11
+ import { StateRepository } from "./state-repository.service.js";
12
+ import { stateMigrations } from "./state-schema.js";
13
+ const CountRow = Schema.Struct({ count: Schema.Number });
14
+ const RevisionJsonRow = Schema.Struct({ revision_json: Schema.String });
15
+ const RevisionBlobCandidateRow = Schema.Struct({
16
+ blob_id: ContentDigest,
17
+ revision_id: ProfileRevisionId,
18
+ resource_id: ResourceId,
19
+ });
20
+ const RunStatusRow = Schema.Struct({
21
+ follower_id: Schema.String,
22
+ status: Schema.String,
23
+ });
24
+ const ActiveRunRow = Schema.Struct({
25
+ id: RunId,
26
+ follower_id: FollowerId,
27
+ revision_id: ProfileRevisionId,
28
+ plan_json: Schema.String,
29
+ started_at: Schema.String,
30
+ });
31
+ const ActionJournalRow = Schema.Struct({
32
+ action_id: ActionId,
33
+ sequence: Schema.Number,
34
+ state: Schema.Literals(["pending", "running", "succeeded", "failed", "skipped"]),
35
+ recorded_at: Schema.String,
36
+ attempt: Schema.Number,
37
+ verification_json: Schema.NullOr(Schema.String),
38
+ rollback_reference: Schema.NullOr(Schema.String),
39
+ removed_resource_json: Schema.NullOr(Schema.String),
40
+ });
41
+ const DriftRow = Schema.Struct({
42
+ sequence: Schema.Number,
43
+ conflict_json: Schema.String,
44
+ recorded_at: Schema.String,
45
+ });
46
+ const AppliedResourceRow = Schema.Struct({
47
+ resource_id: ResourceId,
48
+ revision_id: ProfileRevisionId,
49
+ digest: ContentDigest,
50
+ applied_at: Schema.String,
51
+ owned_files_json: Schema.NullOr(Schema.String),
52
+ schedule_json: Schema.NullOr(Schema.String),
53
+ kind: Schema.NullOr(Schema.String),
54
+ policy: Schema.NullOr(Schema.String),
55
+ target: Schema.NullOr(Schema.String),
56
+ owned_keys_json: Schema.NullOr(Schema.String),
57
+ config_format: Schema.NullOr(Schema.String),
58
+ executable: Schema.NullOr(Schema.Number),
59
+ symlink_target: Schema.NullOr(Schema.String),
60
+ });
61
+ const OwnedFilesSchema = Schema.Array(Schema.Struct({
62
+ path: Schema.NonEmptyString,
63
+ digest: ContentDigest,
64
+ executable: Schema.optional(Schema.Boolean),
65
+ }));
66
+ const OwnedKeysSchema = Schema.Array(Schema.NonEmptyString);
67
+ const StoredScheduleSchema = SyncScheduleSchema;
68
+ const FollowerRow = Schema.Struct({
69
+ id: Schema.String,
70
+ name: Schema.String,
71
+ groups_json: Schema.String,
72
+ revoked: Schema.Number,
73
+ credential_reference: Schema.String,
74
+ enrolled_at: Schema.String,
75
+ });
76
+ const SourceIdentityRow = Schema.Struct({
77
+ key_id: Schema.String,
78
+ public_key_fingerprint: Schema.String,
79
+ });
80
+ const EnrollmentSourceRow = Schema.Struct({
81
+ key_id: Schema.String,
82
+ public_key_fingerprint: Schema.String,
83
+ signing_key_reference: CredentialReference,
84
+ tls_key_reference: CredentialReference,
85
+ tls_certificate_reference: CredentialReference,
86
+ tls_fingerprint: CertificateFingerprint,
87
+ });
88
+ const EnrollmentInvitationRow = Schema.Struct({
89
+ intended_source_fingerprint: CertificateFingerprint,
90
+ tls_fingerprint: CertificateFingerprint,
91
+ endpoint: Schema.String,
92
+ groups_json: Schema.String,
93
+ expires_at: Schema.String,
94
+ used_at: Schema.NullOr(Schema.String),
95
+ });
96
+ const FollowerCredentialRow = Schema.Struct({
97
+ id: Schema.String,
98
+ name: Schema.String,
99
+ groups_json: Schema.String,
100
+ revoked: Schema.Number,
101
+ credential_reference: CredentialReference,
102
+ enrolled_at: Schema.String,
103
+ credential_digest: ContentDigest,
104
+ });
105
+ const FollowerSynchronizationConfigurationRow = Schema.Struct({
106
+ configuration_json: Schema.String,
107
+ });
108
+ const PendingEnrollmentRow = Schema.Struct({
109
+ follower_id: FollowerId,
110
+ code_digest: ContentDigest,
111
+ credential_digest: ContentDigest,
112
+ credential_reference: CredentialReference,
113
+ follower_json: Schema.String,
114
+ });
115
+ const VerificationEvidenceSchema = Schema.Struct({
116
+ status: Schema.Literals(["passed", "failed", "not-run"]),
117
+ method: Schema.NonEmptyString,
118
+ observedDigest: Schema.optional(ContentDigest),
119
+ exitCode: Schema.optional(Schema.Int),
120
+ });
121
+ const sqlError = (operation) => (error) => new RepositorySqlError({ operation, message: String(error) });
122
+ const decodeError = (entity, id) => (error) => new RepositoryDecodeError({ entity, id, message: String(error) });
123
+ const decodeRows = (schema, rows, entity, id) => Schema.decodeUnknownEffect(Schema.Array(schema))(rows).pipe(Effect.mapError(decodeError(entity, id)));
124
+ const parseJson = (schema, text, entity, id) => Effect.try({
125
+ try: () => JSON.parse(text),
126
+ catch: (error) => new RepositoryDecodeError({
127
+ entity,
128
+ id,
129
+ message: String(error),
130
+ }),
131
+ }).pipe(Effect.flatMap(Schema.decodeUnknownEffect(schema)), Effect.mapError((error) => error instanceof RepositoryDecodeError
132
+ ? error
133
+ : new RepositoryDecodeError({ entity, id, message: String(error) })));
134
+ const encodeJson = (value) => JSON.stringify(value);
135
+ const statusCount = Effect.fn("StateRepository.statusCount")(function* (sql, query, entity, id) {
136
+ const rows = yield* query.pipe(Effect.mapError(sqlError(`find ${entity}`)));
137
+ const decoded = yield* decodeRows(CountRow, rows, entity, id);
138
+ return decoded[0]?.count ?? 0;
139
+ });
140
+ const makeRepository = Effect.gen(function* () {
141
+ const sql = yield* SqlClient.SqlClient;
142
+ yield* SqliteMigrator.run({ loader: stateMigrations }).pipe(Effect.mapError((error) => new RepositorySqlError({
143
+ operation: "migrate state schema",
144
+ message: String(error),
145
+ })));
146
+ // A pending enrollment has not issued an active follower credential. Any
147
+ // process restart makes its remote outcome ambiguous, so fail closed by
148
+ // discarding the pending marker; the invitation remains unconsumed and can
149
+ // be safely retried because no active follower identity was issued.
150
+ yield* sql `DELETE FROM pending_enrollments`.pipe(Effect.mapError(sqlError("discard ambiguous pending enrollments")));
151
+ const saveSourceIdentity = Effect.fn("StateRepository.saveSourceIdentity")(function* (identity) {
152
+ yield* sql `
153
+ INSERT INTO source_identity (singleton, key_id, public_key_fingerprint)
154
+ VALUES (1, ${identity.keyId}, ${identity.publicKeyFingerprint})
155
+ ON CONFLICT(singleton) DO UPDATE SET
156
+ key_id = excluded.key_id,
157
+ public_key_fingerprint = excluded.public_key_fingerprint
158
+ `.pipe(Effect.mapError(sqlError("save source identity")));
159
+ });
160
+ const registerFollower = Effect.fn("StateRepository.registerFollower")(function* (input) {
161
+ const follower = input.follower;
162
+ yield* sql `
163
+ INSERT INTO followers (
164
+ id,
165
+ name,
166
+ groups_json,
167
+ revoked,
168
+ credential_reference,
169
+ enrolled_at
170
+ ) VALUES (
171
+ ${follower.id},
172
+ ${follower.name},
173
+ ${encodeJson([...follower.groups])},
174
+ ${follower.revoked ? 1 : 0},
175
+ ${follower.credentialReference},
176
+ ${follower.enrolledAt}
177
+ )
178
+ ON CONFLICT(id) DO UPDATE SET
179
+ name = excluded.name,
180
+ groups_json = excluded.groups_json,
181
+ revoked = excluded.revoked,
182
+ credential_reference = excluded.credential_reference,
183
+ enrolled_at = excluded.enrolled_at
184
+ `.pipe(Effect.mapError(sqlError("register follower")));
185
+ });
186
+ const saveFollowerSynchronizationConfiguration = Effect.fn("StateRepository.saveFollowerSynchronizationConfiguration")(function* (input) {
187
+ const configuration = yield* Schema.decodeUnknownEffect(FollowerSynchronizationConfiguration)(input.configuration).pipe(Effect.mapError(decodeError("follower synchronization configuration", input.configuration.follower.id)));
188
+ if (configuration.follower.credentialReference
189
+ !== configuration.credentialReference
190
+ || configuration.source.signingFingerprint
191
+ !== input.sourceIdentity.publicKeyFingerprint) {
192
+ return yield* new RepositoryDecodeError({
193
+ entity: "follower synchronization configuration",
194
+ id: configuration.follower.id,
195
+ message: "configuration identities or credential references do not match",
196
+ });
197
+ }
198
+ const transaction = Effect.gen(function* () {
199
+ yield* sql `
200
+ INSERT INTO followers (
201
+ id,
202
+ name,
203
+ groups_json,
204
+ revoked,
205
+ credential_reference,
206
+ enrolled_at
207
+ ) VALUES (
208
+ ${configuration.follower.id},
209
+ ${configuration.follower.name},
210
+ ${encodeJson([...configuration.follower.groups])},
211
+ ${configuration.follower.revoked ? 1 : 0},
212
+ ${configuration.credentialReference},
213
+ ${configuration.follower.enrolledAt}
214
+ )
215
+ ON CONFLICT(id) DO UPDATE SET
216
+ name = excluded.name,
217
+ groups_json = excluded.groups_json,
218
+ revoked = excluded.revoked,
219
+ credential_reference = excluded.credential_reference,
220
+ enrolled_at = excluded.enrolled_at
221
+ `;
222
+ yield* sql `
223
+ INSERT INTO source_identity (singleton, key_id, public_key_fingerprint)
224
+ VALUES (
225
+ 1,
226
+ ${input.sourceIdentity.keyId},
227
+ ${input.sourceIdentity.publicKeyFingerprint}
228
+ )
229
+ ON CONFLICT(singleton) DO UPDATE SET
230
+ key_id = excluded.key_id,
231
+ public_key_fingerprint = excluded.public_key_fingerprint
232
+ `;
233
+ yield* sql `
234
+ INSERT INTO follower_sync_configuration (
235
+ singleton,
236
+ follower_id,
237
+ configuration_json,
238
+ updated_at
239
+ ) VALUES (
240
+ 1,
241
+ ${configuration.follower.id},
242
+ ${JSON.stringify(configuration)},
243
+ ${configuration.updatedAt}
244
+ )
245
+ ON CONFLICT(singleton) DO UPDATE SET
246
+ follower_id = excluded.follower_id,
247
+ configuration_json = excluded.configuration_json,
248
+ updated_at = excluded.updated_at
249
+ `;
250
+ });
251
+ yield* sql.withTransaction(transaction).pipe(Effect.mapError(sqlError("save follower synchronization configuration")));
252
+ });
253
+ const getFollowerSynchronizationConfiguration = Effect.fn("StateRepository.getFollowerSynchronizationConfiguration")(function* () {
254
+ const rows = yield* sql `
255
+ SELECT configuration_json
256
+ FROM follower_sync_configuration
257
+ WHERE singleton = 1
258
+ `.pipe(Effect.mapError(sqlError("load follower synchronization configuration")));
259
+ const decoded = yield* decodeRows(FollowerSynchronizationConfigurationRow, rows, "follower synchronization configuration row", "1");
260
+ const row = decoded[0];
261
+ if (row === undefined)
262
+ return undefined;
263
+ return yield* parseJson(FollowerSynchronizationConfiguration, row.configuration_json, "follower synchronization configuration", "1");
264
+ });
265
+ const saveLocalOverlay = Effect.fn("StateRepository.saveLocalOverlay")(function* (input) {
266
+ const configuration = yield* getFollowerSynchronizationConfiguration();
267
+ if (configuration === undefined) {
268
+ return yield* new RepositoryDecodeError({
269
+ entity: "follower synchronization configuration",
270
+ id: "1",
271
+ message: "follower synchronization configuration is not enrolled",
272
+ });
273
+ }
274
+ const entry = yield* Schema.decodeUnknownEffect(LocalOverlayEntrySchema)(input.entry).pipe(Effect.mapError(decodeError("local overlay", input.entry.resource)));
275
+ const localOverlay = [
276
+ ...(configuration.localOverlay ?? []).filter((candidate) => candidate.resource !== entry.resource),
277
+ entry,
278
+ ].sort((left, right) => left.resource.localeCompare(right.resource));
279
+ const nextConfiguration = yield* Schema.decodeUnknownEffect(FollowerSynchronizationConfiguration)({
280
+ ...configuration,
281
+ localOverlay,
282
+ updatedAt: input.updatedAt,
283
+ }).pipe(Effect.mapError(decodeError("follower synchronization configuration", entry.resource)));
284
+ yield* sql `
285
+ UPDATE follower_sync_configuration
286
+ SET configuration_json = ${JSON.stringify(nextConfiguration)},
287
+ updated_at = ${nextConfiguration.updatedAt}
288
+ WHERE singleton = 1
289
+ `.pipe(Effect.mapError(sqlError("save local overlay")));
290
+ });
291
+ const removeLocalOverlay = Effect.fn("StateRepository.removeLocalOverlay")(function* (input) {
292
+ const configuration = yield* getFollowerSynchronizationConfiguration();
293
+ if (configuration === undefined) {
294
+ return yield* new RepositoryDecodeError({
295
+ entity: "follower synchronization configuration",
296
+ id: "1",
297
+ message: "follower synchronization configuration is not enrolled",
298
+ });
299
+ }
300
+ const localOverlay = (configuration.localOverlay ?? [])
301
+ .filter((entry) => entry.resource !== input.resource);
302
+ const nextConfiguration = yield* Schema.decodeUnknownEffect(FollowerSynchronizationConfiguration)({
303
+ ...configuration,
304
+ localOverlay,
305
+ updatedAt: input.updatedAt,
306
+ }).pipe(Effect.mapError(decodeError("follower synchronization configuration", input.resource)));
307
+ yield* sql `
308
+ UPDATE follower_sync_configuration
309
+ SET configuration_json = ${JSON.stringify(nextConfiguration)},
310
+ updated_at = ${nextConfiguration.updatedAt}
311
+ WHERE singleton = 1
312
+ `.pipe(Effect.mapError(sqlError("remove local overlay")));
313
+ });
314
+ const listLocalOverlays = Effect.fn("StateRepository.listLocalOverlays")(function* () {
315
+ const configuration = yield* getFollowerSynchronizationConfiguration();
316
+ return configuration?.localOverlay ?? [];
317
+ });
318
+ const saveEnrollmentSource = Effect.fn("StateRepository.saveEnrollmentSource")(function* (source) {
319
+ const transaction = Effect.gen(function* () {
320
+ yield* sql `
321
+ INSERT INTO source_identity (singleton, key_id, public_key_fingerprint)
322
+ VALUES (
323
+ 1,
324
+ ${source.identity.keyId},
325
+ ${source.identity.publicKeyFingerprint}
326
+ )
327
+ ON CONFLICT(singleton) DO UPDATE SET
328
+ key_id = excluded.key_id,
329
+ public_key_fingerprint = excluded.public_key_fingerprint
330
+ `;
331
+ yield* sql `
332
+ INSERT INTO enrollment_source (
333
+ singleton,
334
+ signing_key_reference,
335
+ tls_key_reference,
336
+ tls_certificate_reference,
337
+ tls_fingerprint
338
+ ) VALUES (
339
+ 1,
340
+ ${source.signingKeyReference},
341
+ ${source.tlsKeyReference},
342
+ ${source.tlsCertificateReference},
343
+ ${source.tlsFingerprint}
344
+ )
345
+ ON CONFLICT(singleton) DO UPDATE SET
346
+ signing_key_reference = excluded.signing_key_reference,
347
+ tls_key_reference = excluded.tls_key_reference,
348
+ tls_certificate_reference = excluded.tls_certificate_reference,
349
+ tls_fingerprint = excluded.tls_fingerprint
350
+ `;
351
+ });
352
+ yield* sql.withTransaction(transaction).pipe(Effect.mapError(sqlError("save enrollment source")));
353
+ });
354
+ const getEnrollmentSource = Effect.fn("StateRepository.getEnrollmentSource")(function* () {
355
+ const rows = yield* sql `
356
+ SELECT
357
+ source_identity.key_id,
358
+ source_identity.public_key_fingerprint,
359
+ enrollment_source.signing_key_reference,
360
+ enrollment_source.tls_key_reference,
361
+ enrollment_source.tls_certificate_reference,
362
+ enrollment_source.tls_fingerprint
363
+ FROM enrollment_source
364
+ INNER JOIN source_identity ON source_identity.singleton = enrollment_source.singleton
365
+ WHERE enrollment_source.singleton = 1
366
+ `.pipe(Effect.mapError(sqlError("load enrollment source")));
367
+ const decoded = yield* decodeRows(EnrollmentSourceRow, rows, "enrollment source", "1");
368
+ const row = decoded[0];
369
+ if (row === undefined)
370
+ return undefined;
371
+ const identity = yield* Schema.decodeUnknownEffect(SourceIdentity)({
372
+ keyId: row.key_id,
373
+ publicKeyFingerprint: row.public_key_fingerprint,
374
+ }).pipe(Effect.mapError(decodeError("source identity", "1")));
375
+ return {
376
+ identity,
377
+ signingKeyReference: row.signing_key_reference,
378
+ tlsKeyReference: row.tls_key_reference,
379
+ tlsCertificateReference: row.tls_certificate_reference,
380
+ tlsFingerprint: row.tls_fingerprint,
381
+ };
382
+ });
383
+ const createEnrollmentInvitation = Effect.fn("StateRepository.createEnrollmentInvitation")(function* (input) {
384
+ yield* sql `
385
+ INSERT INTO enrollment_invitations (
386
+ code_digest,
387
+ nonce_digest,
388
+ intended_source_fingerprint,
389
+ tls_fingerprint,
390
+ endpoint,
391
+ groups_json,
392
+ expires_at,
393
+ used_at
394
+ ) VALUES (
395
+ ${input.codeDigest},
396
+ ${input.nonceDigest},
397
+ ${input.intendedSourceFingerprint},
398
+ ${input.tlsFingerprint},
399
+ ${input.endpoint},
400
+ ${encodeJson([...input.groups])},
401
+ ${input.expiresAt},
402
+ NULL
403
+ )
404
+ `.pipe(Effect.mapError(sqlError("create enrollment invitation")));
405
+ });
406
+ const findEnrollmentInvitation = Effect.fn("StateRepository.findEnrollmentInvitation")(function* (codeDigest) {
407
+ const rows = yield* sql `
408
+ SELECT
409
+ intended_source_fingerprint,
410
+ tls_fingerprint,
411
+ endpoint,
412
+ groups_json,
413
+ expires_at,
414
+ used_at
415
+ FROM enrollment_invitations
416
+ WHERE code_digest = ${codeDigest}
417
+ `.pipe(Effect.mapError(sqlError("find enrollment invitation")));
418
+ const decoded = yield* decodeRows(EnrollmentInvitationRow, rows, "enrollment invitation", codeDigest);
419
+ const row = decoded[0];
420
+ if (row === undefined)
421
+ return undefined;
422
+ const groups = yield* parseJson(Schema.Array(GroupName), row.groups_json, "enrollment invitation groups", codeDigest);
423
+ const storedInvitation = {
424
+ intendedSourceFingerprint: row.intended_source_fingerprint,
425
+ tlsFingerprint: row.tls_fingerprint,
426
+ endpoint: row.endpoint,
427
+ groups,
428
+ expiresAt: row.expires_at,
429
+ };
430
+ if (row.used_at !== null) {
431
+ return { ...storedInvitation, usedAt: row.used_at };
432
+ }
433
+ return storedInvitation;
434
+ });
435
+ const consumeEnrollmentInvitation = Effect.fn("StateRepository.consumeEnrollmentInvitation")(function* (input) {
436
+ const transaction = Effect.gen(function* () {
437
+ const rows = yield* sql `
438
+ SELECT
439
+ intended_source_fingerprint,
440
+ tls_fingerprint,
441
+ endpoint,
442
+ groups_json,
443
+ expires_at,
444
+ used_at
445
+ FROM enrollment_invitations
446
+ WHERE code_digest = ${input.codeDigest}
447
+ `;
448
+ const invitations = yield* decodeRows(EnrollmentInvitationRow, rows, "enrollment invitation", input.codeDigest);
449
+ const invitation = invitations[0];
450
+ if (invitation === undefined) {
451
+ return yield* new EnrollmentStateConflictError({
452
+ reason: "invitation-not-found",
453
+ message: "the invitation is unknown",
454
+ });
455
+ }
456
+ if (invitation.used_at !== null) {
457
+ return yield* new EnrollmentStateConflictError({
458
+ reason: "invitation-used",
459
+ message: "the invitation was already consumed",
460
+ });
461
+ }
462
+ if (Date.parse(invitation.expires_at) <= Date.parse(input.consumedAt)) {
463
+ return yield* new EnrollmentStateConflictError({
464
+ reason: "invitation-expired",
465
+ message: "the invitation has expired",
466
+ });
467
+ }
468
+ if (invitation.intended_source_fingerprint !== input.intendedSourceFingerprint
469
+ || invitation.tls_fingerprint !== input.tlsFingerprint) {
470
+ return yield* new EnrollmentStateConflictError({
471
+ reason: "invitation-mismatch",
472
+ message: "the invitation is not valid for this source",
473
+ });
474
+ }
475
+ const nonceRows = yield* sql `
476
+ SELECT count(*) AS count
477
+ FROM enrollment_invitations
478
+ WHERE code_digest = ${input.codeDigest}
479
+ AND nonce_digest = ${input.nonceDigest}
480
+ `;
481
+ const nonceCount = yield* decodeRows(CountRow, nonceRows, "enrollment invitation nonce", input.codeDigest);
482
+ if ((nonceCount[0]?.count ?? 0) !== 1) {
483
+ return yield* new EnrollmentStateConflictError({
484
+ reason: "invitation-mismatch",
485
+ message: "the invitation nonce is invalid",
486
+ });
487
+ }
488
+ const pendingRows = yield* sql `
489
+ SELECT count(*) AS count
490
+ FROM pending_enrollments
491
+ WHERE code_digest = ${input.codeDigest}
492
+ `;
493
+ const pendingCount = yield* decodeRows(CountRow, pendingRows, "pending enrollment", input.codeDigest);
494
+ if ((pendingCount[0]?.count ?? 0) !== 0) {
495
+ return yield* new EnrollmentStateConflictError({
496
+ reason: "invitation-used",
497
+ message: "the invitation is already pending finalization",
498
+ });
499
+ }
500
+ const identityRows = yield* sql `
501
+ SELECT revoked
502
+ FROM followers
503
+ WHERE id = ${input.follower.id}
504
+ `;
505
+ const identities = yield* decodeRows(Schema.Struct({ revoked: Schema.Number }), identityRows, "follower identity", input.follower.id);
506
+ const existingIdentity = identities[0];
507
+ if (existingIdentity !== undefined && existingIdentity.revoked !== 1) {
508
+ return yield* new EnrollmentStateConflictError({
509
+ reason: "follower-identity-conflict",
510
+ message: "the follower identity is already enrolled",
511
+ });
512
+ }
513
+ const invitationGroups = yield* parseJson(Schema.Array(GroupName), invitation.groups_json, "enrollment invitation groups", input.codeDigest);
514
+ const requestedGroups = [...input.follower.groups];
515
+ if (requestedGroups.length !== invitationGroups.length
516
+ || requestedGroups.some((group, index) => group !== invitationGroups[index])
517
+ || input.follower.revoked) {
518
+ return yield* new EnrollmentStateConflictError({
519
+ reason: "invitation-mismatch",
520
+ message: "the follower enrollment does not match the invitation",
521
+ });
522
+ }
523
+ const credentialRows = yield* sql `
524
+ SELECT count(*) AS count
525
+ FROM follower_credentials
526
+ WHERE credential_digest = ${input.credentialDigest}
527
+ `;
528
+ const credentialCount = yield* decodeRows(CountRow, credentialRows, "follower credential", input.follower.id);
529
+ if ((credentialCount[0]?.count ?? 0) !== 0) {
530
+ return yield* new EnrollmentStateConflictError({
531
+ reason: "credential-conflict",
532
+ message: "the follower credential is already assigned",
533
+ });
534
+ }
535
+ yield* sql `
536
+ INSERT INTO pending_enrollments (
537
+ follower_id,
538
+ code_digest,
539
+ credential_digest,
540
+ credential_reference,
541
+ follower_json,
542
+ created_at
543
+ ) VALUES (
544
+ ${input.follower.id},
545
+ ${input.codeDigest},
546
+ ${input.credentialDigest},
547
+ ${input.credentialReference},
548
+ ${JSON.stringify(input.follower)},
549
+ ${input.consumedAt}
550
+ )
551
+ `;
552
+ });
553
+ yield* sql.withTransaction(transaction).pipe(Effect.mapError((error) => error instanceof EnrollmentStateConflictError
554
+ ? error
555
+ : error instanceof RepositoryDecodeError
556
+ ? error
557
+ : error instanceof RepositorySqlError
558
+ ? error
559
+ : sqlError("consume enrollment invitation")(error)));
560
+ });
561
+ const finalizeEnrollment = Effect.fn("StateRepository.finalizeEnrollment")(function* (input) {
562
+ const transaction = Effect.gen(function* () {
563
+ const pendingRows = yield* sql `
564
+ SELECT
565
+ follower_id,
566
+ code_digest,
567
+ credential_digest,
568
+ credential_reference,
569
+ follower_json
570
+ FROM pending_enrollments
571
+ WHERE follower_id = ${input.follower}
572
+ AND credential_digest = ${input.credentialDigest}
573
+ `;
574
+ const pending = (yield* decodeRows(PendingEnrollmentRow, pendingRows, "pending enrollment", input.follower))[0];
575
+ if (pending === undefined) {
576
+ const activeRows = yield* sql `
577
+ SELECT count(*) AS count
578
+ FROM followers
579
+ INNER JOIN follower_credentials
580
+ ON follower_credentials.follower_id = followers.id
581
+ WHERE followers.id = ${input.follower}
582
+ AND followers.revoked = 0
583
+ AND follower_credentials.credential_digest = ${input.credentialDigest}
584
+ AND follower_credentials.credential_reference = ${input.credentialReference}
585
+ `;
586
+ const active = yield* decodeRows(CountRow, activeRows, "follower enrollment", input.follower);
587
+ if ((active[0]?.count ?? 0) === 1)
588
+ return;
589
+ return yield* new EnrollmentStateConflictError({
590
+ reason: "credential-conflict",
591
+ message: "the pending follower enrollment is unavailable",
592
+ });
593
+ }
594
+ if (pending.credential_reference !== input.credentialReference) {
595
+ return yield* new EnrollmentStateConflictError({
596
+ reason: "credential-conflict",
597
+ message: "the follower credential reference does not match",
598
+ });
599
+ }
600
+ const follower = yield* parseJson(FollowerIdentity, pending.follower_json, "pending follower identity", input.follower);
601
+ const identityRows = yield* sql `
602
+ SELECT revoked
603
+ FROM followers
604
+ WHERE id = ${input.follower}
605
+ `;
606
+ const identities = yield* decodeRows(Schema.Struct({ revoked: Schema.Number }), identityRows, "follower identity", input.follower);
607
+ if (identities[0] !== undefined && identities[0].revoked !== 1) {
608
+ return yield* new EnrollmentStateConflictError({
609
+ reason: "follower-identity-conflict",
610
+ message: "the follower identity is already enrolled",
611
+ });
612
+ }
613
+ const invitationRows = yield* sql `
614
+ SELECT used_at, expires_at
615
+ FROM enrollment_invitations
616
+ WHERE code_digest = ${pending.code_digest}
617
+ `;
618
+ const invitation = (yield* decodeRows(Schema.Struct({
619
+ used_at: Schema.NullOr(Schema.String),
620
+ expires_at: Schema.String,
621
+ }), invitationRows, "enrollment invitation", pending.code_digest))[0];
622
+ if (invitation === undefined || invitation.used_at !== null) {
623
+ return yield* new EnrollmentStateConflictError({
624
+ reason: "invitation-used",
625
+ message: "the enrollment invitation is no longer available",
626
+ });
627
+ }
628
+ if (Date.parse(invitation.expires_at) <= Date.now()) {
629
+ return yield* new EnrollmentStateConflictError({
630
+ reason: "invitation-expired",
631
+ message: "the enrollment invitation has expired",
632
+ });
633
+ }
634
+ if (identities[0] === undefined) {
635
+ yield* sql `
636
+ INSERT INTO followers (
637
+ id,
638
+ name,
639
+ groups_json,
640
+ revoked,
641
+ credential_reference,
642
+ enrolled_at
643
+ ) VALUES (
644
+ ${follower.id},
645
+ ${follower.name},
646
+ ${encodeJson([...follower.groups])},
647
+ 0,
648
+ ${input.credentialReference},
649
+ ${follower.enrolledAt}
650
+ )
651
+ `;
652
+ yield* sql `
653
+ INSERT INTO follower_credentials (
654
+ follower_id,
655
+ credential_digest,
656
+ credential_reference
657
+ ) VALUES (
658
+ ${follower.id},
659
+ ${input.credentialDigest},
660
+ ${input.credentialReference}
661
+ )
662
+ `;
663
+ }
664
+ else {
665
+ yield* sql `
666
+ UPDATE followers
667
+ SET
668
+ name = ${follower.name},
669
+ groups_json = ${encodeJson([...follower.groups])},
670
+ revoked = 0,
671
+ credential_reference = ${input.credentialReference},
672
+ enrolled_at = ${follower.enrolledAt}
673
+ WHERE id = ${follower.id}
674
+ `;
675
+ yield* sql `
676
+ UPDATE follower_credentials
677
+ SET
678
+ credential_digest = ${input.credentialDigest},
679
+ credential_reference = ${input.credentialReference}
680
+ WHERE follower_id = ${follower.id}
681
+ `;
682
+ const credentialUpdated = yield* statusCount(sql, sql `
683
+ SELECT count(*) AS count
684
+ FROM follower_credentials
685
+ WHERE follower_id = ${follower.id}
686
+ `, "follower credential", follower.id);
687
+ if (credentialUpdated !== 1) {
688
+ return yield* new EnrollmentStateConflictError({
689
+ reason: "follower-identity-conflict",
690
+ message: "the revoked follower credential is unavailable",
691
+ });
692
+ }
693
+ }
694
+ yield* sql `
695
+ UPDATE enrollment_invitations
696
+ SET used_at = ${new Date().toISOString()}
697
+ WHERE code_digest = ${pending.code_digest} AND used_at IS NULL
698
+ `;
699
+ const changedRows = yield* sql `SELECT changes() AS count`;
700
+ const changed = yield* decodeRows(CountRow, changedRows, "enrollment invitation", pending.code_digest);
701
+ if ((changed[0]?.count ?? 0) !== 1) {
702
+ return yield* new EnrollmentStateConflictError({
703
+ reason: "invitation-used",
704
+ message: "the enrollment invitation was consumed concurrently",
705
+ });
706
+ }
707
+ yield* sql `
708
+ DELETE FROM pending_enrollments
709
+ WHERE follower_id = ${pending.follower_id}
710
+ AND credential_digest = ${pending.credential_digest}
711
+ `;
712
+ });
713
+ yield* sql.withTransaction(transaction).pipe(Effect.mapError((error) => error instanceof EnrollmentStateConflictError
714
+ ? error
715
+ : error instanceof RepositoryDecodeError
716
+ ? error
717
+ : error instanceof RepositorySqlError
718
+ ? error
719
+ : sqlError("finalize enrollment")(error)));
720
+ });
721
+ const cancelPendingEnrollment = Effect.fn("StateRepository.cancelPendingEnrollment")(function* (input) {
722
+ yield* sql `
723
+ DELETE FROM pending_enrollments
724
+ WHERE credential_digest = ${input.credentialDigest}
725
+ `.pipe(Effect.mapError(sqlError("cancel pending enrollment")));
726
+ });
727
+ const listPendingEnrollments = Effect.fn("StateRepository.listPendingEnrollments")(function* () {
728
+ const rows = yield* sql `
729
+ SELECT follower_id, code_digest, credential_digest, credential_reference, follower_json
730
+ FROM pending_enrollments
731
+ ORDER BY created_at, follower_id
732
+ `.pipe(Effect.mapError(sqlError("list pending enrollments")));
733
+ const decoded = yield* decodeRows(PendingEnrollmentRow, rows, "pending enrollment", "all");
734
+ return yield* Effect.forEach(decoded, (row) => parseJson(FollowerIdentity, row.follower_json, "pending follower identity", row.follower_id)
735
+ .pipe(Effect.as({
736
+ follower: row.follower_id,
737
+ codeDigest: row.code_digest,
738
+ credentialDigest: row.credential_digest,
739
+ credentialReference: row.credential_reference,
740
+ })));
741
+ });
742
+ const decodeFollowerCredential = Effect.fn("StateRepository.decodeFollowerCredential")(function* (row) {
743
+ const groups = yield* parseJson(Schema.Array(GroupName), row.groups_json, "follower groups", row.id);
744
+ const follower = yield* Schema.decodeUnknownEffect(FollowerIdentity)({
745
+ id: row.id,
746
+ name: row.name,
747
+ groups,
748
+ revoked: row.revoked === 1,
749
+ credentialReference: row.credential_reference,
750
+ enrolledAt: row.enrolled_at,
751
+ }).pipe(Effect.mapError(decodeError("follower", row.id)));
752
+ return {
753
+ follower,
754
+ credentialDigest: row.credential_digest,
755
+ credentialReference: row.credential_reference,
756
+ };
757
+ });
758
+ const findFollowerCredential = Effect.fn("StateRepository.findFollowerCredential")(function* (credentialDigest) {
759
+ const rows = yield* sql `
760
+ SELECT
761
+ followers.id,
762
+ followers.name,
763
+ followers.groups_json,
764
+ followers.revoked,
765
+ followers.credential_reference,
766
+ followers.enrolled_at,
767
+ follower_credentials.credential_digest
768
+ FROM follower_credentials
769
+ INNER JOIN followers ON followers.id = follower_credentials.follower_id
770
+ WHERE follower_credentials.credential_digest = ${credentialDigest}
771
+ `.pipe(Effect.mapError(sqlError("find follower credential")));
772
+ const decoded = yield* decodeRows(FollowerCredentialRow, rows, "follower credential", credentialDigest);
773
+ const row = decoded[0];
774
+ return row === undefined ? undefined : yield* decodeFollowerCredential(row);
775
+ });
776
+ const getFollowerCredential = Effect.fn("StateRepository.getFollowerCredential")(function* (follower) {
777
+ const rows = yield* sql `
778
+ SELECT
779
+ followers.id,
780
+ followers.name,
781
+ followers.groups_json,
782
+ followers.revoked,
783
+ followers.credential_reference,
784
+ followers.enrolled_at,
785
+ follower_credentials.credential_digest
786
+ FROM follower_credentials
787
+ INNER JOIN followers ON followers.id = follower_credentials.follower_id
788
+ WHERE follower_credentials.follower_id = ${follower}
789
+ `.pipe(Effect.mapError(sqlError("get follower credential")));
790
+ const decoded = yield* decodeRows(FollowerCredentialRow, rows, "follower credential", follower);
791
+ const row = decoded[0];
792
+ if (row === undefined)
793
+ return yield* new FollowerNotFoundError({ follower });
794
+ return yield* decodeFollowerCredential(row);
795
+ });
796
+ const revokeFollower = Effect.fn("StateRepository.revokeFollower")(function* (follower) {
797
+ const count = yield* statusCount(sql, sql `SELECT count(*) AS count FROM followers WHERE id = ${follower}`, "follower", follower);
798
+ if (count === 0)
799
+ return yield* new FollowerNotFoundError({ follower });
800
+ yield* sql `UPDATE followers SET revoked = 1 WHERE id = ${follower}`.pipe(Effect.mapError(sqlError("revoke follower")));
801
+ });
802
+ const updateFollowerGroups = Effect.fn("StateRepository.updateFollowerGroups")(function* (follower, groups) {
803
+ const count = yield* statusCount(sql, sql `SELECT count(*) AS count FROM followers WHERE id = ${follower}`, "follower", follower);
804
+ if (count === 0)
805
+ return yield* new FollowerNotFoundError({ follower });
806
+ yield* sql `
807
+ UPDATE followers
808
+ SET groups_json = ${encodeJson([...groups])}
809
+ WHERE id = ${follower}
810
+ `.pipe(Effect.mapError(sqlError("update follower groups")));
811
+ });
812
+ const publishRevision = Effect.fn("StateRepository.publishRevision")(function* (input) {
813
+ const revision = input.revision;
814
+ const encoded = encodeJson(revision);
815
+ const transaction = Effect.gen(function* () {
816
+ const existingRows = yield* sql `
817
+ SELECT revision_json
818
+ FROM profile_revisions
819
+ WHERE id = ${revision.id}
820
+ `;
821
+ const existing = yield* decodeRows(RevisionJsonRow, existingRows, "profile revision row", revision.id);
822
+ if (existing.length > 0) {
823
+ const stored = yield* parseJson(ProfileRevisionSchema, existing[0].revision_json, "profile revision", revision.id);
824
+ if (encodeJson(stored) === encoded)
825
+ return;
826
+ return yield* new RevisionImmutableError({
827
+ revision: revision.id,
828
+ message: "the revision id already names different immutable content",
829
+ });
830
+ }
831
+ yield* sql `
832
+ INSERT INTO profile_revisions (
833
+ id,
834
+ profile_id,
835
+ sequence,
836
+ canonical_bytes,
837
+ digest,
838
+ signature,
839
+ published_at,
840
+ revision_json
841
+ ) VALUES (
842
+ ${revision.id},
843
+ ${revision.profileId},
844
+ ${revision.sequence},
845
+ ${revision.canonicalBytes},
846
+ ${revision.digest},
847
+ ${revision.signature},
848
+ ${revision.publishedAt},
849
+ ${encoded}
850
+ )
851
+ `;
852
+ for (const resource of revision.resources) {
853
+ for (const blob of resource.blobs) {
854
+ yield* sql `
855
+ INSERT OR IGNORE INTO profile_revision_blobs (
856
+ blob_id,
857
+ revision_id,
858
+ resource_id
859
+ ) VALUES (
860
+ ${Schema.decodeUnknownSync(BlobId)(blob)},
861
+ ${revision.id},
862
+ ${resource.id}
863
+ )
864
+ `;
865
+ }
866
+ }
867
+ });
868
+ yield* sql.withTransaction(transaction).pipe(Effect.catchTag("SqlError", (error) => Effect.fail(error.reason._tag === "UniqueViolation"
869
+ ? new RevisionImmutableError({
870
+ revision: revision.id,
871
+ message: `profile sequence is already published: ${error.reason.constraint}`,
872
+ })
873
+ : sqlError("publish profile revision")(error))));
874
+ });
875
+ const getRevision = Effect.fn("StateRepository.getRevision")(function* (revision) {
876
+ const rows = yield* sql `
877
+ SELECT revision_json
878
+ FROM profile_revisions
879
+ WHERE id = ${revision}
880
+ `.pipe(Effect.mapError(sqlError("load profile revision")));
881
+ const decoded = yield* decodeRows(RevisionJsonRow, rows, "profile revision row", revision);
882
+ const row = decoded[0];
883
+ if (row === undefined)
884
+ return yield* new RevisionNotFoundError({ revision });
885
+ return yield* parseJson(ProfileRevisionSchema, row.revision_json, "profile revision", revision);
886
+ });
887
+ const findRevision = Effect.fn("StateRepository.findRevision")(function* (revision) {
888
+ const rows = yield* sql `
889
+ SELECT revision_json
890
+ FROM profile_revisions
891
+ WHERE id = ${revision}
892
+ `.pipe(Effect.mapError(sqlError("find profile revision")));
893
+ const decoded = yield* decodeRows(RevisionJsonRow, rows, "profile revision row", revision);
894
+ const row = decoded[0];
895
+ if (row === undefined)
896
+ return undefined;
897
+ return yield* parseJson(ProfileRevisionSchema, row.revision_json, "profile revision", revision);
898
+ });
899
+ const getLatestRevision = Effect.fn("StateRepository.getLatestRevision")(function* (profile) {
900
+ const rows = yield* sql `
901
+ SELECT revision_json
902
+ FROM profile_revisions
903
+ WHERE profile_id = ${profile}
904
+ ORDER BY sequence DESC
905
+ LIMIT 1
906
+ `.pipe(Effect.mapError(sqlError("load latest profile revision")));
907
+ const decoded = yield* decodeRows(RevisionJsonRow, rows, "profile revision row", profile);
908
+ const row = decoded[0];
909
+ if (row === undefined)
910
+ return undefined;
911
+ return yield* parseJson(ProfileRevisionSchema, row.revision_json, "profile revision", profile);
912
+ });
913
+ const listRevisions = Effect.fn("StateRepository.listRevisions")(function* () {
914
+ const rows = yield* sql `
915
+ SELECT revision_json
916
+ FROM profile_revisions
917
+ ORDER BY profile_id, sequence
918
+ `.pipe(Effect.mapError(sqlError("list profile revisions")));
919
+ const decoded = yield* decodeRows(RevisionJsonRow, rows, "profile revision row", "all");
920
+ const revisions = [];
921
+ for (const row of decoded) {
922
+ revisions.push(yield* parseJson(ProfileRevisionSchema, row.revision_json, "profile revision", "all"));
923
+ }
924
+ return revisions;
925
+ });
926
+ const listRevisionBlobCandidates = Effect.fn("StateRepository.listRevisionBlobCandidates")(function* (blob) {
927
+ const rows = yield* sql `
928
+ SELECT
929
+ profile_revision_blobs.blob_id,
930
+ profile_revision_blobs.revision_id,
931
+ profile_revision_blobs.resource_id
932
+ FROM profile_revision_blobs
933
+ WHERE profile_revision_blobs.blob_id = ${blob}
934
+ ORDER BY profile_revision_blobs.revision_id, profile_revision_blobs.resource_id
935
+ `.pipe(Effect.mapError(sqlError("list profile revision blob candidates")));
936
+ const decoded = yield* decodeRows(RevisionBlobCandidateRow, rows, "profile revision blob candidates", blob);
937
+ return decoded.map((row) => ({
938
+ blob: row.blob_id,
939
+ revision: row.revision_id,
940
+ resource: row.resource_id,
941
+ }));
942
+ });
943
+ const loadAppliedResources = Effect.fn("StateRepository.loadAppliedResources")(function* (follower) {
944
+ const rows = yield* sql `
945
+ SELECT resource_id, revision_id, digest, applied_at, owned_files_json, schedule_json,
946
+ kind, policy, target, owned_keys_json, config_format
947
+ , executable, symlink_target
948
+ FROM applied_resources
949
+ WHERE follower_id = ${follower}
950
+ ORDER BY resource_id
951
+ `.pipe(Effect.mapError(sqlError("load applied resources")));
952
+ const stored = yield* decodeRows(AppliedResourceRow, rows, "applied resources", follower);
953
+ return yield* Effect.forEach(stored, (row) => Effect.gen(function* () {
954
+ const ownedFiles = row.owned_files_json === null
955
+ ? undefined
956
+ : yield* parseJson(OwnedFilesSchema, row.owned_files_json, "applied resource owned files", row.resource_id);
957
+ const schedule = row.schedule_json === null
958
+ ? undefined
959
+ : yield* parseJson(StoredScheduleSchema, row.schedule_json, "applied resource schedule", row.resource_id);
960
+ const ownedKeys = row.owned_keys_json === null
961
+ ? undefined
962
+ : yield* parseJson(OwnedKeysSchema, row.owned_keys_json, "applied resource owned keys", row.resource_id);
963
+ return yield* Schema.decodeUnknownEffect(AppliedResourceRecordSchema)({
964
+ resource: row.resource_id,
965
+ revision: row.revision_id,
966
+ digest: row.digest,
967
+ appliedAt: row.applied_at,
968
+ kind: row.kind ?? undefined,
969
+ policy: row.policy ?? undefined,
970
+ target: row.target ?? undefined,
971
+ executable: row.executable === null
972
+ ? undefined
973
+ : row.executable === 1,
974
+ symlinkTo: row.symlink_target ?? undefined,
975
+ ownedFiles,
976
+ ownedKeys,
977
+ configFormat: row.config_format ?? undefined,
978
+ schedule,
979
+ }).pipe(Effect.mapError(decodeError("applied resource", row.resource_id)));
980
+ }));
981
+ });
982
+ const startRun = Effect.fn("StateRepository.startRun")(function* (input) {
983
+ const transaction = Effect.gen(function* () {
984
+ const followerCount = yield* statusCount(sql, sql `SELECT COUNT(*) AS count FROM followers WHERE id = ${input.follower}`, "follower", input.follower);
985
+ if (followerCount === 0) {
986
+ return yield* new FollowerNotFoundError({ follower: input.follower });
987
+ }
988
+ const revisionCount = yield* statusCount(sql, sql `SELECT COUNT(*) AS count FROM profile_revisions WHERE id = ${input.revision}`, "profile revision", input.revision);
989
+ if (revisionCount === 0) {
990
+ return yield* new RevisionNotFoundError({ revision: input.revision });
991
+ }
992
+ const recoverableRunCount = yield* statusCount(sql, sql `
993
+ SELECT COUNT(*) AS count
994
+ FROM synchronization_runs
995
+ WHERE follower_id = ${input.follower}
996
+ AND status IN ('applying', 'Interrupted')
997
+ `, "recoverable synchronization run", input.follower);
998
+ if (recoverableRunCount > 0) {
999
+ return yield* new ActiveRunExistsError({ follower: input.follower });
1000
+ }
1001
+ yield* sql `
1002
+ INSERT INTO synchronization_runs (
1003
+ id,
1004
+ follower_id,
1005
+ revision_id,
1006
+ status,
1007
+ plan_json,
1008
+ started_at
1009
+ ) VALUES (
1010
+ ${input.id},
1011
+ ${input.follower},
1012
+ ${input.revision},
1013
+ 'applying',
1014
+ ${encodeJson(input.plan)},
1015
+ ${input.startedAt}
1016
+ )
1017
+ `;
1018
+ for (let ordinal = 0; ordinal < input.plan.actions.length; ordinal += 1) {
1019
+ const action = input.plan.actions[ordinal];
1020
+ yield* sql `
1021
+ INSERT INTO run_actions (run_id, action_id, plan_ordinal)
1022
+ VALUES (${input.id}, ${action.id}, ${ordinal})
1023
+ `;
1024
+ yield* sql `
1025
+ INSERT INTO action_journal (
1026
+ run_id,
1027
+ action_id,
1028
+ sequence,
1029
+ state,
1030
+ recorded_at,
1031
+ attempt
1032
+ ) VALUES (
1033
+ ${input.id},
1034
+ ${action.id},
1035
+ ${ordinal},
1036
+ 'pending',
1037
+ ${input.startedAt},
1038
+ 0
1039
+ )
1040
+ `;
1041
+ }
1042
+ });
1043
+ yield* sql.withTransaction(transaction).pipe(Effect.catchTag("SqlError", (error) => {
1044
+ if (error.reason._tag === "UniqueViolation"
1045
+ && error.reason.constraint.includes("synchronization_runs.follower_id")) {
1046
+ return Effect.fail(new ActiveRunExistsError({ follower: input.follower }));
1047
+ }
1048
+ return Effect.fail(sqlError("start synchronization run")(error));
1049
+ }));
1050
+ });
1051
+ const loadRunStatus = Effect.fn("StateRepository.loadRunStatus")(function* (run) {
1052
+ const rows = yield* sql `
1053
+ SELECT follower_id, status
1054
+ FROM synchronization_runs
1055
+ WHERE id = ${run}
1056
+ `.pipe(Effect.mapError(sqlError("load synchronization run")));
1057
+ const decoded = yield* decodeRows(RunStatusRow, rows, "synchronization run", run);
1058
+ const row = decoded[0];
1059
+ if (row === undefined)
1060
+ return yield* new RunNotFoundError({ run });
1061
+ return row;
1062
+ });
1063
+ const journalAction = Effect.fn("StateRepository.journalAction")(function* (input) {
1064
+ const transaction = Effect.gen(function* () {
1065
+ const run = yield* loadRunStatus(input.run);
1066
+ if (run.status !== "applying" && run.status !== "Interrupted") {
1067
+ return yield* new InvalidRunTransitionError({
1068
+ run: input.run,
1069
+ message: `cannot journal an action while run status is ${run.status}`,
1070
+ });
1071
+ }
1072
+ const actionCount = yield* statusCount(sql, sql `
1073
+ SELECT COUNT(*) AS count
1074
+ FROM run_actions
1075
+ WHERE run_id = ${input.run} AND action_id = ${input.action}
1076
+ `, "planned action", input.action);
1077
+ if (actionCount === 0) {
1078
+ return yield* new ActionNotInPlanError({
1079
+ run: input.run,
1080
+ action: input.action,
1081
+ });
1082
+ }
1083
+ const sequenceRows = yield* sql `
1084
+ SELECT COALESCE(MAX(sequence), -1) + 1 AS count
1085
+ FROM action_journal
1086
+ WHERE run_id = ${input.run}
1087
+ `;
1088
+ const sequences = yield* decodeRows(CountRow, sequenceRows, "action journal sequence", input.run);
1089
+ const sequence = sequences[0]?.count ?? 0;
1090
+ yield* sql `
1091
+ INSERT INTO action_journal (
1092
+ run_id,
1093
+ action_id,
1094
+ sequence,
1095
+ state,
1096
+ recorded_at,
1097
+ attempt,
1098
+ verification_json,
1099
+ rollback_reference,
1100
+ removed_resource_json
1101
+ ) VALUES (
1102
+ ${input.run},
1103
+ ${input.action},
1104
+ ${sequence},
1105
+ ${input.state},
1106
+ ${input.recordedAt},
1107
+ ${input.attempt},
1108
+ ${input.verification === undefined ? null : encodeJson(input.verification)},
1109
+ ${input.rollbackReference ?? null},
1110
+ ${input.removedResourceRecord === undefined
1111
+ ? null
1112
+ : encodeJson(input.removedResourceRecord)}
1113
+ )
1114
+ `;
1115
+ const removedResource = input.removedResourceRecord?.resource
1116
+ ?? input.removedResource;
1117
+ if (removedResource !== undefined) {
1118
+ yield* sql `
1119
+ DELETE FROM applied_resources
1120
+ WHERE follower_id = ${run.follower_id}
1121
+ AND resource_id = ${removedResource}
1122
+ `;
1123
+ }
1124
+ if (input.appliedResource !== undefined) {
1125
+ const record = input.appliedResource;
1126
+ yield* sql `
1127
+ INSERT INTO applied_resources (
1128
+ follower_id,
1129
+ resource_id,
1130
+ revision_id,
1131
+ digest,
1132
+ applied_at,
1133
+ owned_files_json,
1134
+ schedule_json,
1135
+ kind,
1136
+ policy,
1137
+ target,
1138
+ owned_keys_json,
1139
+ config_format,
1140
+ executable,
1141
+ symlink_target
1142
+ ) VALUES (
1143
+ ${run.follower_id},
1144
+ ${record.resource},
1145
+ ${record.revision},
1146
+ ${record.digest},
1147
+ ${record.appliedAt},
1148
+ ${record.ownedFiles === undefined
1149
+ ? null
1150
+ : encodeJson(JSON.parse(JSON.stringify(record.ownedFiles)))}
1151
+ , ${record.schedule === undefined
1152
+ ? null
1153
+ : encodeJson(JSON.parse(JSON.stringify(record.schedule)))}
1154
+ , ${record.kind ?? null}
1155
+ , ${record.policy ?? null}
1156
+ , ${record.target ?? null}
1157
+ , ${record.ownedKeys === undefined
1158
+ ? null
1159
+ : encodeJson(JSON.parse(JSON.stringify(record.ownedKeys)))}
1160
+ , ${record.configFormat ?? null}
1161
+ , ${record.executable === undefined
1162
+ ? null
1163
+ : record.executable ? 1 : 0}
1164
+ , ${record.symlinkTo ?? null}
1165
+ )
1166
+ ON CONFLICT(follower_id, resource_id) DO UPDATE SET
1167
+ revision_id = excluded.revision_id,
1168
+ digest = excluded.digest,
1169
+ applied_at = excluded.applied_at,
1170
+ owned_files_json = excluded.owned_files_json
1171
+ , schedule_json = excluded.schedule_json
1172
+ , kind = excluded.kind
1173
+ , policy = excluded.policy
1174
+ , target = excluded.target
1175
+ , owned_keys_json = excluded.owned_keys_json
1176
+ , config_format = excluded.config_format
1177
+ , executable = excluded.executable
1178
+ , symlink_target = excluded.symlink_target
1179
+ `;
1180
+ }
1181
+ });
1182
+ yield* sql.withTransaction(transaction).pipe(Effect.mapError((error) => "_tag" in error && error._tag !== "SqlError"
1183
+ ? error
1184
+ : sqlError("journal synchronization action")(error)));
1185
+ });
1186
+ const recordDrift = Effect.fn("StateRepository.recordDrift")(function* (input) {
1187
+ const transaction = Effect.gen(function* () {
1188
+ yield* loadRunStatus(input.run);
1189
+ const sequenceRows = yield* sql `
1190
+ SELECT COALESCE(MAX(sequence), -1) + 1 AS count
1191
+ FROM drift_records
1192
+ WHERE run_id = ${input.run}
1193
+ `;
1194
+ const sequences = yield* decodeRows(CountRow, sequenceRows, "drift sequence", input.run);
1195
+ yield* sql `
1196
+ INSERT INTO drift_records (
1197
+ run_id,
1198
+ sequence,
1199
+ conflict_json,
1200
+ recorded_at
1201
+ ) VALUES (
1202
+ ${input.run},
1203
+ ${sequences[0]?.count ?? 0},
1204
+ ${encodeJson(input.conflict)},
1205
+ ${input.recordedAt}
1206
+ )
1207
+ `;
1208
+ });
1209
+ yield* sql.withTransaction(transaction).pipe(Effect.mapError((error) => "_tag" in error && error._tag !== "SqlError"
1210
+ ? error
1211
+ : sqlError("record follower drift")(error)));
1212
+ });
1213
+ const completeRun = Effect.fn("StateRepository.completeRun")(function* (input) {
1214
+ const transaction = Effect.gen(function* () {
1215
+ const run = yield* loadRunStatus(input.run);
1216
+ if (run.status !== "applying" && run.status !== "Interrupted") {
1217
+ return yield* new InvalidRunTransitionError({
1218
+ run: input.run,
1219
+ message: `run is already ${run.status}`,
1220
+ });
1221
+ }
1222
+ if (input.outcome.run !== input.run) {
1223
+ return yield* new InvalidRunTransitionError({
1224
+ run: input.run,
1225
+ message: `outcome belongs to run ${input.outcome.run}`,
1226
+ });
1227
+ }
1228
+ yield* sql `
1229
+ UPDATE synchronization_runs
1230
+ SET
1231
+ status = ${input.outcome.outcome},
1232
+ completed_at = ${input.completedAt},
1233
+ outcome_json = ${encodeJson(input.outcome)}
1234
+ WHERE id = ${input.run}
1235
+ `;
1236
+ for (const resource of input.removedResources ?? []) {
1237
+ yield* sql `
1238
+ DELETE FROM applied_resources
1239
+ WHERE follower_id = ${run.follower_id}
1240
+ AND resource_id = ${resource}
1241
+ `;
1242
+ }
1243
+ for (const record of input.appliedResources) {
1244
+ yield* sql `
1245
+ INSERT INTO applied_resources (
1246
+ follower_id,
1247
+ resource_id,
1248
+ revision_id,
1249
+ digest,
1250
+ applied_at,
1251
+ owned_files_json,
1252
+ schedule_json,
1253
+ kind,
1254
+ policy,
1255
+ target,
1256
+ owned_keys_json,
1257
+ config_format,
1258
+ executable,
1259
+ symlink_target
1260
+ ) VALUES (
1261
+ ${run.follower_id},
1262
+ ${record.resource},
1263
+ ${record.revision},
1264
+ ${record.digest},
1265
+ ${record.appliedAt},
1266
+ ${record.ownedFiles === undefined
1267
+ ? null
1268
+ : encodeJson(JSON.parse(JSON.stringify(record.ownedFiles)))}
1269
+ , ${record.schedule === undefined
1270
+ ? null
1271
+ : encodeJson(JSON.parse(JSON.stringify(record.schedule)))}
1272
+ , ${record.kind ?? null}
1273
+ , ${record.policy ?? null}
1274
+ , ${record.target ?? null}
1275
+ , ${record.ownedKeys === undefined
1276
+ ? null
1277
+ : encodeJson(JSON.parse(JSON.stringify(record.ownedKeys)))}
1278
+ , ${record.configFormat ?? null}
1279
+ , ${record.executable === undefined
1280
+ ? null
1281
+ : record.executable ? 1 : 0}
1282
+ , ${record.symlinkTo ?? null}
1283
+ )
1284
+ ON CONFLICT(follower_id, resource_id) DO UPDATE SET
1285
+ revision_id = excluded.revision_id,
1286
+ digest = excluded.digest,
1287
+ applied_at = excluded.applied_at,
1288
+ owned_files_json = excluded.owned_files_json
1289
+ , schedule_json = excluded.schedule_json
1290
+ , kind = excluded.kind
1291
+ , policy = excluded.policy
1292
+ , target = excluded.target
1293
+ , owned_keys_json = excluded.owned_keys_json
1294
+ , config_format = excluded.config_format
1295
+ , executable = excluded.executable
1296
+ , symlink_target = excluded.symlink_target
1297
+ `;
1298
+ }
1299
+ });
1300
+ yield* sql.withTransaction(transaction).pipe(Effect.mapError((error) => "_tag" in error && error._tag !== "SqlError"
1301
+ ? error
1302
+ : sqlError("complete synchronization run")(error)));
1303
+ });
1304
+ const loadRecovery = Effect.fn("StateRepository.loadRecovery")(function* (follower) {
1305
+ const runRows = yield* sql `
1306
+ SELECT id, follower_id, revision_id, plan_json, started_at
1307
+ FROM synchronization_runs
1308
+ WHERE follower_id = ${follower}
1309
+ AND status IN ('applying', 'Interrupted')
1310
+ ORDER BY started_at DESC
1311
+ LIMIT 1
1312
+ `.pipe(Effect.mapError(sqlError("load active synchronization run")));
1313
+ const runs = yield* decodeRows(ActiveRunRow, runRows, "active synchronization run", follower);
1314
+ const row = runs[0];
1315
+ if (row === undefined)
1316
+ return undefined;
1317
+ const plan = yield* parseJson(SynchronizationPlanSchema, row.plan_json, "synchronization plan", row.id);
1318
+ const journalRows = yield* sql `
1319
+ SELECT
1320
+ action_id,
1321
+ sequence,
1322
+ state,
1323
+ recorded_at,
1324
+ attempt,
1325
+ verification_json,
1326
+ rollback_reference,
1327
+ removed_resource_json
1328
+ FROM action_journal
1329
+ WHERE run_id = ${row.id}
1330
+ ORDER BY sequence
1331
+ `.pipe(Effect.mapError(sqlError("load action journal")));
1332
+ const journal = yield* decodeRows(ActionJournalRow, journalRows, "action journal", row.id);
1333
+ const actions = [];
1334
+ for (const event of journal) {
1335
+ let verification;
1336
+ if (event.verification_json !== null) {
1337
+ verification = yield* parseJson(VerificationEvidenceSchema, event.verification_json, "action verification", `${row.id}:${event.sequence}`);
1338
+ }
1339
+ const base = {
1340
+ action: event.action_id,
1341
+ ordinal: event.sequence,
1342
+ state: event.state,
1343
+ recordedAt: event.recorded_at,
1344
+ attempt: event.attempt,
1345
+ };
1346
+ const rollbackReference = event.rollback_reference;
1347
+ const removedResource = event.removed_resource_json === null
1348
+ ? undefined
1349
+ : yield* parseJson(AppliedResourceRecordSchema, event.removed_resource_json, "removed resource ownership", `${row.id}:${event.sequence}`);
1350
+ const pushAction = (action) => {
1351
+ if (removedResource === undefined) {
1352
+ actions.push(action);
1353
+ }
1354
+ else {
1355
+ actions.push({ ...action, removedResource });
1356
+ }
1357
+ };
1358
+ if (verification === undefined) {
1359
+ if (rollbackReference === null) {
1360
+ pushAction(base);
1361
+ }
1362
+ else {
1363
+ pushAction({
1364
+ ...base,
1365
+ rollbackReference,
1366
+ });
1367
+ }
1368
+ }
1369
+ else if (rollbackReference === null) {
1370
+ pushAction({
1371
+ ...base,
1372
+ verification,
1373
+ });
1374
+ }
1375
+ else {
1376
+ pushAction({
1377
+ ...base,
1378
+ verification,
1379
+ rollbackReference,
1380
+ });
1381
+ }
1382
+ }
1383
+ const driftRows = yield* sql `
1384
+ SELECT sequence, conflict_json, recorded_at
1385
+ FROM drift_records
1386
+ WHERE run_id = ${row.id}
1387
+ ORDER BY sequence
1388
+ `.pipe(Effect.mapError(sqlError("load drift records")));
1389
+ const encodedDrift = yield* decodeRows(DriftRow, driftRows, "drift records", row.id);
1390
+ const drift = [];
1391
+ for (const stored of encodedDrift) {
1392
+ const conflict = yield* parseJson(DriftConflictSchema, stored.conflict_json, "drift conflict", `${row.id}:${stored.sequence}`);
1393
+ drift.push({
1394
+ ordinal: stored.sequence,
1395
+ conflict,
1396
+ recordedAt: stored.recorded_at,
1397
+ });
1398
+ }
1399
+ const appliedResources = yield* loadAppliedResources(follower);
1400
+ const removedResources = [
1401
+ ...new Map(actions
1402
+ .flatMap((event) => event.removedResource === undefined
1403
+ ? []
1404
+ : [[event.removedResource.resource, event.removedResource]])).values(),
1405
+ ].sort((left, right) => left.resource.localeCompare(right.resource));
1406
+ return {
1407
+ run: {
1408
+ id: row.id,
1409
+ follower: row.follower_id,
1410
+ revision: row.revision_id,
1411
+ startedAt: row.started_at,
1412
+ plan,
1413
+ },
1414
+ actions,
1415
+ drift,
1416
+ appliedResources,
1417
+ removedResources,
1418
+ };
1419
+ });
1420
+ const loadState = Effect.fn("StateRepository.loadState")(function* (follower) {
1421
+ const followerRows = yield* sql `
1422
+ SELECT
1423
+ id,
1424
+ name,
1425
+ groups_json,
1426
+ revoked,
1427
+ credential_reference,
1428
+ enrolled_at
1429
+ FROM followers
1430
+ WHERE id = ${follower}
1431
+ `.pipe(Effect.mapError(sqlError("load follower state")));
1432
+ const followers = yield* decodeRows(FollowerRow, followerRows, "follower", follower);
1433
+ const storedFollower = followers[0];
1434
+ if (storedFollower === undefined) {
1435
+ return yield* new FollowerNotFoundError({ follower });
1436
+ }
1437
+ const groups = yield* parseJson(Schema.Array(Schema.String), storedFollower.groups_json, "follower groups", follower);
1438
+ const decodedFollower = yield* Schema.decodeUnknownEffect(FollowerIdentity)({
1439
+ id: storedFollower.id,
1440
+ name: storedFollower.name,
1441
+ groups,
1442
+ revoked: storedFollower.revoked === 1,
1443
+ credentialReference: storedFollower.credential_reference,
1444
+ enrolledAt: storedFollower.enrolled_at,
1445
+ }).pipe(Effect.mapError(decodeError("follower", follower)));
1446
+ const identityRows = yield* sql `
1447
+ SELECT key_id, public_key_fingerprint
1448
+ FROM source_identity
1449
+ WHERE singleton = 1
1450
+ `.pipe(Effect.mapError(sqlError("load source identity")));
1451
+ const identities = yield* decodeRows(SourceIdentityRow, identityRows, "source identity", "1");
1452
+ const storedIdentity = identities[0];
1453
+ const sourceIdentity = storedIdentity === undefined
1454
+ ? undefined
1455
+ : yield* Schema.decodeUnknownEffect(SourceIdentity)({
1456
+ keyId: storedIdentity.key_id,
1457
+ publicKeyFingerprint: storedIdentity.public_key_fingerprint,
1458
+ }).pipe(Effect.mapError(decodeError("source identity", "1")));
1459
+ const activeRecovery = yield* loadRecovery(follower);
1460
+ if (sourceIdentity === undefined && activeRecovery === undefined) {
1461
+ return { follower: decodedFollower };
1462
+ }
1463
+ if (sourceIdentity === undefined) {
1464
+ return { follower: decodedFollower, activeRecovery };
1465
+ }
1466
+ if (activeRecovery === undefined) {
1467
+ return { follower: decodedFollower, sourceIdentity };
1468
+ }
1469
+ return { follower: decodedFollower, sourceIdentity, activeRecovery };
1470
+ });
1471
+ return StateRepository.of({
1472
+ saveSourceIdentity,
1473
+ registerFollower,
1474
+ saveFollowerSynchronizationConfiguration,
1475
+ getFollowerSynchronizationConfiguration,
1476
+ saveLocalOverlay,
1477
+ removeLocalOverlay,
1478
+ listLocalOverlays,
1479
+ saveEnrollmentSource,
1480
+ getEnrollmentSource,
1481
+ createEnrollmentInvitation,
1482
+ findEnrollmentInvitation,
1483
+ consumeEnrollmentInvitation,
1484
+ finalizeEnrollment,
1485
+ cancelPendingEnrollment,
1486
+ listPendingEnrollments,
1487
+ findFollowerCredential,
1488
+ getFollowerCredential,
1489
+ revokeFollower,
1490
+ updateFollowerGroups,
1491
+ publishRevision,
1492
+ getRevision,
1493
+ findRevision,
1494
+ getLatestRevision,
1495
+ listRevisions,
1496
+ listRevisionBlobCandidates,
1497
+ loadAppliedResources,
1498
+ startRun,
1499
+ journalAction,
1500
+ recordDrift,
1501
+ completeRun,
1502
+ loadRecovery,
1503
+ loadState,
1504
+ });
1505
+ });
1506
+ export const StateRepositoryLive = Layer.effect(StateRepository, makeRepository);
1507
+ export const stateRepositoryLayer = (filename) => StateRepositoryLive.pipe(Layer.provide(SqliteClient.layer({ filename })));