ignotum 0.0.2 → 0.0.4

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 (64) hide show
  1. package/README.md +23 -7
  2. package/dist/cli/bin.mjs +32313 -751
  3. package/dist/cli/bin.mjs.map +1 -1
  4. package/dist/runtime/{api-Cpx57mk3.d.ts → api-D2bsAz4T.d.ts} +3 -2
  5. package/dist/runtime/{api-BXXIZc_Q.js → api-DtX8qPrq.js} +58 -27
  6. package/dist/runtime/api-DtX8qPrq.js.map +1 -0
  7. package/dist/runtime/client.d.ts +11 -4
  8. package/dist/runtime/client.js +343 -79
  9. package/dist/runtime/client.js.map +1 -1
  10. package/dist/runtime/{result-B2W-z2wG.js → descriptor-t6BOEGw9-BTHR-ZMv.js} +119 -4
  11. package/dist/runtime/descriptor-t6BOEGw9-BTHR-ZMv.js.map +1 -0
  12. package/dist/runtime/id-Btwac71X-B9OeBzvq.d.ts +9 -0
  13. package/dist/runtime/id-D570vudg.js +26 -0
  14. package/dist/runtime/id-D570vudg.js.map +1 -0
  15. package/dist/runtime/{index-BgWROoyk.d.ts → index-Dl_VQGmA.d.ts} +114 -31
  16. package/dist/runtime/internal/api.d.ts +1 -1
  17. package/dist/runtime/internal/api.js +1 -1
  18. package/dist/runtime/internal/host.d.ts +44 -0
  19. package/dist/runtime/internal/host.js +213 -0
  20. package/dist/runtime/internal/host.js.map +1 -0
  21. package/dist/runtime/internal/server.d.ts +1 -1
  22. package/dist/runtime/internal/server.js +1 -1
  23. package/dist/runtime/internal/types.d.ts +1 -1
  24. package/dist/runtime/internal/types.js +1 -1
  25. package/dist/runtime/{result-C1ZdsM6Y.d.ts → result-BgcHn25t.d.ts} +21 -6
  26. package/dist/runtime/schema-B-jMZEbs.js +227 -0
  27. package/dist/runtime/schema-B-jMZEbs.js.map +1 -0
  28. package/dist/runtime/server.d.ts +2 -2
  29. package/dist/runtime/server.js +2 -2
  30. package/package.json +15 -5
  31. package/src/cli/agent-files.ts +6 -2
  32. package/src/cli/app-configuration.ts +135 -0
  33. package/src/cli/bin.ts +24 -1
  34. package/src/cli/build/artifact.ts +95 -0
  35. package/src/cli/build/client.ts +103 -0
  36. package/src/cli/build/server.ts +526 -0
  37. package/src/cli/client-config.ts +141 -0
  38. package/src/cli/client-entry.ts +152 -0
  39. package/src/cli/client-plugin.ts +86 -16
  40. package/src/cli/codegen.ts +5 -5
  41. package/src/cli/command.ts +41 -9
  42. package/src/cli/control-client.ts +343 -0
  43. package/src/cli/deploy.ts +217 -0
  44. package/src/cli/dev.ts +43 -108
  45. package/src/cli/module-boundaries.ts +183 -0
  46. package/src/cli/{new-project.ts → new-app.ts} +86 -86
  47. package/src/cli/package-manager.ts +9 -9
  48. package/src/client/app.ts +15 -0
  49. package/src/client/errors.ts +1 -17
  50. package/src/client/index.ts +2 -0
  51. package/src/client/sync.ts +172 -120
  52. package/src/dev-runtime/database.ts +253 -96
  53. package/src/dev-runtime/dev-database.ts +14 -14
  54. package/src/dev-runtime/functions.ts +57 -88
  55. package/src/dev-runtime/id.ts +2 -10
  56. package/src/dev-runtime/migrations.ts +68 -0
  57. package/src/dev-runtime/sync.ts +266 -83
  58. package/src/internal/function-definition.ts +77 -0
  59. package/src/internal/host.ts +1 -0
  60. package/src/internal/http-paths.ts +3 -1
  61. package/dist/runtime/api-BXXIZc_Q.js.map +0 -1
  62. package/dist/runtime/result-B2W-z2wG.js.map +0 -1
  63. package/dist/runtime/schema-CNEVLF7D.js +0 -116
  64. package/dist/runtime/schema-CNEVLF7D.js.map +0 -1
@@ -3,14 +3,19 @@ import {
3
3
  Deferred,
4
4
  Duration,
5
5
  Effect,
6
+ Fiber,
6
7
  HashMap,
7
8
  Layer,
8
9
  ManagedRuntime,
9
- Predicate,
10
10
  Schedule,
11
11
  Schema,
12
12
  } from "effect";
13
13
  import * as Socket from "effect/unstable/socket/Socket";
14
+ import * as BrowserCrypto from "@effect/platform-browser/BrowserCrypto";
15
+
16
+ import { encodeCanonicalJson } from "@ignotum/contracts/json";
17
+ import { type AppStateRevision } from "@ignotum/contracts/runtime/hosted";
18
+ import { IdGenerator } from "@ignotum/shared/id";
14
19
 
15
20
  import type {
16
21
  ErrorValue,
@@ -20,12 +25,15 @@ import type {
20
25
  import { failureFromWire, pending, Result } from "@ignotum/contracts/runtime/result";
21
26
  import {
22
27
  ClientMessageJson,
23
- RequestId,
28
+ DeploymentChangedCloseCode,
29
+ InvocationId,
24
30
  ServerMessageJson,
25
31
  SubscriptionId,
26
32
  decodeTransportValue,
27
33
  type ClientMessage,
28
34
  type FunctionAddress,
35
+ type ServerMessage,
36
+ type SyncHandshake,
29
37
  type WireResult,
30
38
  } from "@ignotum/contracts/runtime/sync";
31
39
  import {
@@ -33,7 +41,6 @@ import {
33
41
  ConnectionUnavailable,
34
42
  InvalidClientMessage,
35
43
  InvalidServerMessage,
36
- MutationOutcomeUnknown,
37
44
  ServerProtocolError,
38
45
  clientErrorReporterLayer,
39
46
  reportClientError,
@@ -49,6 +56,7 @@ interface QueryEntry {
49
56
  readonly listeners: Set<Listener>;
50
57
  readonly subscriptionId: SubscriptionId;
51
58
  generation: number;
59
+ revision: AppStateRevision | undefined;
52
60
  references: number;
53
61
  result: QueryResult<unknown, ErrorValue> | ClientInfrastructureError;
54
62
  }
@@ -65,34 +73,19 @@ interface QueryCache {
65
73
  readonly entries: () => Iterable<QueryEntry>;
66
74
  readonly getById: (id: SubscriptionId) => QueryEntry | undefined;
67
75
  readonly setError: (id: SubscriptionId, error: ClientInfrastructureError) => void;
68
- readonly setResult: (id: SubscriptionId, result: QueryResult<unknown, ErrorValue>) => void;
76
+ readonly setResult: (
77
+ id: SubscriptionId,
78
+ result: QueryResult<unknown, ErrorValue>,
79
+ revision: AppStateRevision,
80
+ ) => void;
69
81
  readonly observe: (
70
82
  functionAddress: FunctionAddress,
71
83
  args: Schema.Json,
72
84
  ) => Effect.Effect<QueryObserver>;
73
85
  }
74
86
 
75
- const canonicalJson = (value: Schema.Json): string => {
76
- if (
77
- Predicate.isNull(value) ||
78
- Predicate.isString(value) ||
79
- Predicate.isNumber(value) ||
80
- Predicate.isBoolean(value)
81
- ) {
82
- return JSON.stringify(value);
83
- }
84
- if (globalThis.Array.isArray(value)) {
85
- return `[${value.map(canonicalJson).join(",")}]`;
86
- }
87
-
88
- return `{${Object.entries(value)
89
- .sort(([left], [right]) => left.localeCompare(right))
90
- .map(([key, child]) => `${JSON.stringify(key)}:${canonicalJson(child)}`)
91
- .join(",")}}`;
92
- };
93
-
94
87
  const queryKey = (functionAddress: FunctionAddress, args: Schema.Json): string =>
95
- `${functionAddress}:${canonicalJson(args)}`;
88
+ encodeCanonicalJson([functionAddress, args]);
96
89
 
97
90
  const subscribeMessage = (entry: QueryEntry): QueryLifecycleMessage => ({
98
91
  type: "Subscribe",
@@ -102,7 +95,7 @@ const subscribeMessage = (entry: QueryEntry): QueryLifecycleMessage => ({
102
95
  });
103
96
 
104
97
  const makeQueryCache = (
105
- allocateId: () => SubscriptionId,
98
+ allocateId: Effect.Effect<SubscriptionId>,
106
99
  sendLifecycleMessage: (message: QueryLifecycleMessage) => Effect.Effect<void>,
107
100
  ) => {
108
101
  const queries = new Map<string, QueryEntry>();
@@ -121,9 +114,10 @@ const makeQueryCache = (
121
114
  function: functionAddress,
122
115
  generation: 0,
123
116
  listeners: new Set(),
117
+ revision: undefined,
124
118
  references: 0,
125
119
  result: pending(),
126
- subscriptionId: allocateId(),
120
+ subscriptionId: yield* allocateId,
127
121
  };
128
122
  queries.set(key, created);
129
123
  queryKeysById.set(created.subscriptionId, key);
@@ -185,10 +179,14 @@ const makeQueryCache = (
185
179
  listener();
186
180
  }
187
181
  },
188
- setResult: (id, result) => {
182
+ setResult: (id, result, revision) => {
189
183
  const key = queryKeysById.get(id);
190
184
  const entry = key === undefined ? undefined : queries.get(key);
191
185
  if (entry === undefined) return;
186
+ if (entry.revision !== undefined && revision < entry.revision) {
187
+ return;
188
+ }
189
+ entry.revision = revision;
192
190
  entry.result = result;
193
191
  for (const listener of entry.listeners) {
194
192
  listener();
@@ -207,7 +205,33 @@ const resultFromWire = Effect.fn("SyncClient.resultFromWire")((wire: WireResult)
207
205
  ),
208
206
  );
209
207
 
210
- export const syncClientInternals = { makeQueryCache, queryIdentity: queryKey, resultFromWire };
208
+ export const shouldReloadForCloseCode = (code: number): boolean =>
209
+ code === DeploymentChangedCloseCode;
210
+
211
+ const handshakesMatch = (first: SyncHandshake, next: SyncHandshake): boolean =>
212
+ first.appId === next.appId &&
213
+ first.deploymentId === next.deploymentId &&
214
+ first.generation === next.generation;
215
+
216
+ type HandshakeDecision = "Accept" | "Ignore" | "Reload";
217
+
218
+ const handshakeDecision = (
219
+ remembered: SyncHandshake | undefined,
220
+ acceptedOnConnection: boolean,
221
+ next: SyncHandshake,
222
+ ): HandshakeDecision => {
223
+ if (remembered !== undefined && !handshakesMatch(remembered, next)) return "Reload";
224
+ return acceptedOnConnection ? "Ignore" : "Accept";
225
+ };
226
+
227
+ export const syncClientInternals = {
228
+ makeQueryCache,
229
+ handshakeDecision,
230
+ queryIdentity: queryKey,
231
+ resultFromWire,
232
+ handshakesMatch,
233
+ shouldReloadForCloseCode,
234
+ };
211
235
 
212
236
  const socketUrl = (): string => {
213
237
  const url = new URL(syncPath, globalThis.location.href);
@@ -226,12 +250,13 @@ interface SyncClientService {
226
250
  ) => Effect.Effect<QueryObserver, ClientInfrastructureError>;
227
251
  }
228
252
 
229
- interface PendingMutation {
253
+ interface PendingInvocation {
230
254
  readonly deferred: Deferred.Deferred<
231
255
  IgnotumResult<unknown, ErrorValue>,
232
256
  ClientInfrastructureError
233
257
  >;
234
258
  readonly function: FunctionAddress;
259
+ readonly message: Extract<ClientMessage, { readonly type: "Invoke" }>;
235
260
  }
236
261
 
237
262
  export class SyncClient extends Context.Service<SyncClient, SyncClientService>()(
@@ -240,8 +265,10 @@ export class SyncClient extends Context.Service<SyncClient, SyncClientService>()
240
265
  static readonly layer = Layer.effect(
241
266
  SyncClient,
242
267
  Effect.gen(function* () {
243
- let nextOperationId = 0;
244
- let mutations = HashMap.empty<RequestId, PendingMutation>();
268
+ const ids = yield* IdGenerator;
269
+ let invocations = HashMap.empty<InvocationId, PendingInvocation>();
270
+ let rememberedHandshake: SyncHandshake | undefined;
271
+ let reloadRequested = false;
245
272
  let writer: SocketWriter | undefined;
246
273
 
247
274
  const encode = (message: ClientMessage) =>
@@ -274,56 +301,49 @@ export class SyncClient extends Context.Service<SyncClient, SyncClientService>()
274
301
  )
275
302
  : sendWith(writer, message);
276
303
 
277
- const queryCache = makeQueryCache(
278
- () => SubscriptionId.make(nextOperationId++),
279
- (message) =>
280
- writer === undefined
281
- ? Effect.void
282
- : send(message).pipe(
283
- Effect.catchTags({
284
- ConnectionUnavailable: reportClientError,
285
- InvalidClientMessage: reportClientError,
286
- }),
287
- ),
304
+ const queryCache = makeQueryCache(ids.generate(SubscriptionId), (message) =>
305
+ writer === undefined
306
+ ? Effect.void
307
+ : send(message).pipe(
308
+ Effect.catchTags({
309
+ ConnectionUnavailable: reportClientError,
310
+ InvalidClientMessage: reportClientError,
311
+ }),
312
+ ),
288
313
  );
289
314
 
290
- const rejectMutation = (requestId: RequestId, error: ClientInfrastructureError) => {
291
- const pendingMutation = HashMap.getUnsafe(mutations, requestId);
292
- if (pendingMutation === undefined) {
315
+ const rejectInvocation = (id: InvocationId, error: ClientInfrastructureError) => {
316
+ const pendingInvocation = HashMap.getUnsafe(invocations, id);
317
+ if (pendingInvocation === undefined) {
293
318
  return Effect.void;
294
319
  }
295
- mutations = HashMap.remove(mutations, requestId);
320
+ invocations = HashMap.remove(invocations, id);
296
321
  return reportClientError(error).pipe(
297
- Effect.andThen(Deferred.fail(pendingMutation.deferred, error)),
322
+ Effect.andThen(Deferred.fail(pendingInvocation.deferred, error)),
298
323
  Effect.asVoid,
299
324
  );
300
325
  };
301
326
 
302
- const handleMessage = Effect.fn("SyncClient.handleMessage")(function* (text: string) {
303
- const message = yield* Schema.decodeEffect(ServerMessageJson)(text).pipe(
304
- Effect.mapError((cause) =>
305
- InvalidServerMessage.make({
306
- cause,
307
- message: "The server returned an invalid sync message.",
308
- }),
309
- ),
310
- );
311
-
327
+ const handleMessage = Effect.fn("SyncClient.handleMessage")(function* (
328
+ message: Exclude<ServerMessage, SyncHandshake | { readonly type: "DeploymentChanged" }>,
329
+ ) {
312
330
  switch (message.type) {
313
- case "QuerySnapshot": {
331
+ case "Snapshot": {
314
332
  const entry = queryCache.getById(message.id);
315
333
  if (entry === undefined) return;
316
334
  yield* resultFromWire(message.result).pipe(
317
- Effect.tap((result) => Effect.sync(() => queryCache.setResult(message.id, result))),
335
+ Effect.tap((result) =>
336
+ Effect.sync(() => queryCache.setResult(message.id, result, message.revision)),
337
+ ),
318
338
  );
319
339
  return;
320
340
  }
321
- case "MutationResult": {
322
- const pendingMutation = HashMap.getUnsafe(mutations, message.id);
323
- if (pendingMutation === undefined) return;
324
- mutations = HashMap.remove(mutations, message.id);
341
+ case "Result": {
342
+ const pendingInvocation = HashMap.getUnsafe(invocations, message.id);
343
+ if (pendingInvocation === undefined) return;
344
+ invocations = HashMap.remove(invocations, message.id);
325
345
  yield* resultFromWire(message.result).pipe(
326
- Effect.flatMap((result) => Deferred.succeed(pendingMutation.deferred, result)),
346
+ Effect.flatMap((result) => Deferred.succeed(pendingInvocation.deferred, result)),
327
347
  );
328
348
  return;
329
349
  }
@@ -339,9 +359,9 @@ export class SyncClient extends Context.Service<SyncClient, SyncClientService>()
339
359
  message: message.message,
340
360
  operation: message.operation,
341
361
  });
342
- if (message.operation?.type === "Mutate") {
343
- yield* rejectMutation(message.operation.id, error);
344
- } else if (message.operation?.type === "Query") {
362
+ if (message.operation?.type === "Invocation") {
363
+ yield* rejectInvocation(message.operation.id, error);
364
+ } else if (message.operation?.type === "Subscription") {
345
365
  queryCache.setError(message.operation.id, error);
346
366
  yield* reportClientError(error);
347
367
  } else {
@@ -352,36 +372,81 @@ export class SyncClient extends Context.Service<SyncClient, SyncClientService>()
352
372
  }
353
373
  });
354
374
 
355
- const disconnect = Effect.gen(function* () {
375
+ const requestReload = () => {
376
+ if (reloadRequested) return;
377
+ reloadRequested = true;
378
+ writer = undefined;
379
+ globalThis.location.reload();
380
+ };
381
+
382
+ const disconnect = Effect.sync(() => {
356
383
  writer = undefined;
357
- const current = mutations;
358
- mutations = HashMap.empty();
359
- yield* Effect.forEach(HashMap.entries(current), ([id, pendingMutation]) => {
360
- const error = MutationOutcomeUnknown.make({
361
- function: pendingMutation.function,
362
- id,
363
- message: "The connection closed before the mutation outcome was known.",
364
- });
365
- return reportClientError(error).pipe(
366
- Effect.andThen(Deferred.fail(pendingMutation.deferred, error)),
367
- );
368
- });
369
384
  });
370
385
 
371
386
  const connect = Effect.scoped(
372
387
  Effect.gen(function* () {
388
+ if (reloadRequested) return yield* Effect.never;
373
389
  const socket = yield* Socket.makeWebSocket(socketUrl(), {
374
- closeCodeIsError: () => false,
390
+ closeCodeIsError: (code) => {
391
+ if (shouldReloadForCloseCode(code)) requestReload();
392
+ return false;
393
+ },
375
394
  });
376
395
  const write = yield* socket.writer;
377
- const onOpen = Effect.gen(function* () {
396
+ const handshakeReceived = yield* Deferred.make<void>();
397
+ let handshakeAccepted = false;
398
+ const acceptHandshake = Effect.fn("SyncClient.acceptHandshake")(function* (
399
+ handshake: SyncHandshake,
400
+ ) {
401
+ const decision = handshakeDecision(rememberedHandshake, handshakeAccepted, handshake);
402
+ if (decision === "Reload") {
403
+ requestReload();
404
+ return;
405
+ }
406
+ if (decision === "Ignore") return;
407
+ rememberedHandshake = handshake;
408
+ handshakeAccepted = true;
378
409
  writer = write;
379
- yield* Effect.forEach(queryCache.entries(), (entry) =>
380
- sendWith(write, subscribeMessage(entry)),
410
+ yield* Deferred.succeed(handshakeReceived, undefined);
411
+ yield* Effect.gen(function* () {
412
+ yield* Effect.forEach(queryCache.entries(), (entry) =>
413
+ sendWith(write, subscribeMessage(entry)),
414
+ );
415
+ yield* Effect.forEach(HashMap.values(invocations), (pendingInvocation) =>
416
+ sendWith(write, pendingInvocation.message),
417
+ );
418
+ }).pipe(Effect.tapError(reportClientError), Effect.ignore);
419
+ });
420
+
421
+ const handleText = Effect.fn("SyncClient.handleText")(function* (text: string) {
422
+ const message = yield* Schema.decodeEffect(ServerMessageJson)(text).pipe(
423
+ Effect.mapError((cause) =>
424
+ InvalidServerMessage.make({
425
+ cause,
426
+ message: "The server returned an invalid sync message.",
427
+ }),
428
+ ),
381
429
  );
382
- }).pipe(Effect.tapError(reportClientError), Effect.ignore);
430
+ if (message.type === "Handshake") return yield* acceptHandshake(message);
431
+ if (message.type === "DeploymentChanged") {
432
+ requestReload();
433
+ return;
434
+ }
435
+ if (!handshakeAccepted) {
436
+ return yield* InvalidServerMessage.make({
437
+ cause: new Error("The server sent a sync message before the handshake."),
438
+ message: "The server returned a sync message before the handshake.",
439
+ });
440
+ }
441
+ return yield* handleMessage(message);
442
+ });
383
443
 
384
- yield* socket.runString(handleMessage, { onOpen });
444
+ const run = yield* socket.runString(handleText).pipe(Effect.forkChild);
445
+ yield* Effect.raceFirst(
446
+ Deferred.await(handshakeReceived).pipe(Effect.timeout("10 seconds")),
447
+ Fiber.join(run),
448
+ );
449
+ yield* Fiber.join(run);
385
450
  }),
386
451
  ).pipe(
387
452
  Effect.mapError((cause) =>
@@ -422,58 +487,45 @@ export class SyncClient extends Context.Service<SyncClient, SyncClientService>()
422
487
 
423
488
  const mutate: SyncClientService["mutate"] = Effect.fn("SyncClient.mutate")(
424
489
  function* (functionAddress, args) {
425
- const requestId = RequestId.make(nextOperationId++);
490
+ const invocationId = yield* ids.generate(InvocationId);
426
491
  const deferred = yield* Deferred.make<
427
492
  IgnotumResult<unknown, ErrorValue>,
428
493
  ClientInfrastructureError
429
494
  >();
430
- mutations = HashMap.set(mutations, requestId, {
431
- deferred,
432
- function: functionAddress,
433
- });
434
495
  const message: ClientMessage = {
435
- type: "Mutate",
436
- id: requestId,
496
+ type: "Invoke",
497
+ id: invocationId,
498
+ kind: "Mutation",
437
499
  function: functionAddress,
438
500
  args,
439
501
  };
440
- const sendMutation = Effect.gen(function* () {
441
- const mutationWriter = writer;
442
- if (mutationWriter === undefined) {
443
- return yield* ConnectionUnavailable.make({
444
- message: "The sync connection is not ready.",
445
- });
446
- }
447
- return yield* sendWith(mutationWriter, message).pipe(
448
- Effect.mapError((error) =>
449
- Schema.is(InvalidClientMessage)(error)
450
- ? error
451
- : MutationOutcomeUnknown.make({
452
- cause: error,
453
- function: functionAddress,
454
- id: requestId,
455
- message: "The connection failed while sending the mutation.",
456
- }),
457
- ),
458
- );
502
+ invocations = HashMap.set(invocations, invocationId, {
503
+ deferred,
504
+ function: functionAddress,
505
+ message,
459
506
  });
460
- yield* sendMutation.pipe(
461
- Effect.tapError(reportClientError),
462
- Effect.tapError(() =>
463
- Effect.sync(() => (mutations = HashMap.remove(mutations, requestId))),
464
- ),
465
- );
507
+ if (writer !== undefined) {
508
+ yield* sendWith(writer, message).pipe(
509
+ Effect.catchTags({
510
+ ConnectionUnavailable: reportClientError,
511
+ InvalidClientMessage: (error) => rejectInvocation(invocationId, error),
512
+ }),
513
+ );
514
+ }
466
515
  return yield* Deferred.await(deferred);
467
516
  },
468
517
  );
469
518
 
470
519
  return SyncClient.of({ mutate, observe });
471
520
  }),
472
- ).pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal));
521
+ ).pipe(Layer.provide(Layer.merge(Socket.layerWebSocketConstructorGlobal, BrowserCrypto.layer)));
473
522
  }
474
523
 
475
524
  export const syncRuntime = ManagedRuntime.make(
476
- SyncClient.layer.pipe(Layer.provideMerge(clientErrorReporterLayer)),
525
+ SyncClient.layer.pipe(
526
+ Layer.provideMerge(clientErrorReporterLayer),
527
+ Layer.provideMerge(IdGenerator.layer),
528
+ ),
477
529
  );
478
530
 
479
531
  // SAFETY: Vite adds this optional property to browser development modules.