@shortlink-org/portolan 0.2.2 → 0.2.3

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 (47) hide show
  1. package/README.md +7 -2
  2. package/cli/portolan.mjs +36 -6
  3. package/package.json +4 -4
  4. package/plugins/portolan-go.wasm +0 -0
  5. package/scripts/builtin-plugins.mjs +3 -2
  6. package/scripts/catalog-sources.mjs +2 -1
  7. package/scripts/catalog-sources.test.mjs +11 -1
  8. package/scripts/delivery-presets.mjs +207 -24
  9. package/scripts/diff.mjs +5 -1
  10. package/scripts/local-api.mjs +26 -377
  11. package/scripts/local-api.test.mjs +50 -0
  12. package/scripts/local-discovery.mjs +378 -0
  13. package/scripts/manifest.mjs +42 -1
  14. package/scripts/manifest.test.mjs +24 -1
  15. package/scripts/run-builtin.mjs +4 -2
  16. package/scripts/schema.mjs +4 -0
  17. package/scripts/site-docs.mjs +2 -2
  18. package/src/app/CatalogApp.tsx +4 -4
  19. package/src/app/Sidebar.tsx +13 -730
  20. package/src/app/SidebarFlowSections.tsx +251 -0
  21. package/src/app/SidebarFooter.tsx +160 -0
  22. package/src/app/SidebarTree.tsx +322 -0
  23. package/src/catalog-index.ts +485 -0
  24. package/src/catalog-model.ts +1339 -0
  25. package/src/catalog-validation.ts +1570 -0
  26. package/src/catalog.test.ts +16 -0
  27. package/src/catalog.ts +6 -3306
  28. package/src/components/{PageHeader.test.ts → PageHeader.test.tsx} +9 -10
  29. package/src/components/ProblemRow.tsx +3 -0
  30. package/src/components/SourcePreview.tsx +1 -1
  31. package/src/index.css +0 -17
  32. package/src/landing/LandingPage.tsx +4 -4
  33. package/src/lib/all-problems.ts +1 -1
  34. package/src/lib/derive.ts +1 -0
  35. package/src/lib/local-api.ts +19 -7
  36. package/src/lib/motion.test.ts +4 -2
  37. package/src/lib/motion.tsx +5 -4
  38. package/src/lib/proto-problems.test.ts +170 -3
  39. package/src/lib/proto-problems.ts +176 -4
  40. package/src/merge.test.ts +46 -0
  41. package/src/merge.ts +35 -1
  42. package/src/pages/Settings.tsx +1 -126
  43. package/src/pages/settings/AboutSettings.tsx +129 -0
  44. package/src/pages/settings/DeliverySettings.tsx +71 -15
  45. package/src/selection/pages.test.ts +14 -1
  46. package/src/selection/pages.ts +9 -3
  47. package/vite.config.ts +3 -1
@@ -0,0 +1,485 @@
1
+ import type {
2
+ Adr,
3
+ Aggregate,
4
+ Block,
5
+ BlockKind,
6
+ BoundedContext,
7
+ Catalog,
8
+ Column,
9
+ Enum,
10
+ Event,
11
+ External,
12
+ Flow,
13
+ ProtoModule,
14
+ RedisKeyspace,
15
+ RpcCall,
16
+ RpcService,
17
+ Service,
18
+ Store,
19
+ Table,
20
+ Term,
21
+ View,
22
+ } from "./catalog-model.ts";
23
+ import {
24
+ allExternals,
25
+ allModules,
26
+ allStores,
27
+ allTerms,
28
+ aggregateBlocks,
29
+ columnId,
30
+ enumsOf,
31
+ storeViews,
32
+ viewReads,
33
+ walkSteps,
34
+ } from "./catalog-model.ts";
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Indexes
38
+ // ---------------------------------------------------------------------------
39
+
40
+ /** Everything needed to render or link a block without walking the tree again. */
41
+ export interface BlockOwner {
42
+ block: Block;
43
+ kind: BlockKind;
44
+ aggregate: Aggregate;
45
+ service: Service;
46
+ context: BoundedContext;
47
+ }
48
+
49
+ /** An enum and everything that owns it, so a value can be drawn without a lookup. */
50
+ export interface EnumOwner {
51
+ enum: Enum;
52
+ aggregate: Aggregate;
53
+ service: Service;
54
+ context: BoundedContext;
55
+ }
56
+
57
+ /** A column and everything holding it, so a row can be drawn without a lookup. */
58
+ export interface ColumnOwner {
59
+ column: Column;
60
+ table: Table;
61
+ store: Store;
62
+ }
63
+
64
+ /** The same, for a column of a view. Kept apart so `owner.table` never lies. */
65
+ export interface ViewColumnOwner {
66
+ column: Column;
67
+ view: View;
68
+ store: Store;
69
+ }
70
+
71
+ /**
72
+ * The block a `maps` path points into, by name. A path is "<Type>.<Field>", and
73
+ * the type is resolved inside the aggregate the table already says it persists
74
+ * — the only scope in which a bare type name is unambiguous.
75
+ */
76
+ export function mapsBlockId(
77
+ aggregate: Aggregate | undefined,
78
+ maps: string | undefined,
79
+ ): string | null {
80
+ if (!aggregate || !maps) return null;
81
+ const head = maps.split(".")[0];
82
+ if (!head) return null;
83
+ const found = aggregateBlocks(aggregate).find((b) => b.block.name === head);
84
+ return found ? found.block.id : null;
85
+ }
86
+
87
+ /** The field half of a `maps` path — everything after the type name. */
88
+ export function mapsFieldPath(maps: string): string {
89
+ const at = maps.indexOf(".");
90
+ return at < 0 ? maps : maps.slice(at + 1);
91
+ }
92
+
93
+ export interface CatalogIndex {
94
+ catalog: Catalog;
95
+ serviceById: Map<string, Service>;
96
+ serviceContext: Map<string, BoundedContext>;
97
+ aggregateById: Map<string, Aggregate>;
98
+ aggregateOwner: Map<string, Service>;
99
+ eventById: Map<string, Event>;
100
+ eventOwner: Map<string, { service: Service; aggregate: Aggregate }>;
101
+ /**
102
+ * wire name -> the event that goes out under it.
103
+ *
104
+ * The one lookup that starts from the bus rather than from the catalog. A
105
+ * subscriber names a message and knows nothing else about it, and this is
106
+ * what turns that name back into the event, its aggregate and its owner.
107
+ * Only events are in here: a channel declared by a document is a promise,
108
+ * and a promise is not a page anything can link to.
109
+ */
110
+ eventByWireName: Map<string, Event>;
111
+ /** value object and entity id -> the block and everything that owns it */
112
+ blockById: Map<string, BlockOwner>;
113
+ /** enum id -> the enum and everything that owns it */
114
+ enumById: Map<string, EnumOwner>;
115
+ /** defs key -> ids of the blocks that name it */
116
+ blocksByDef: Map<string, string[]>;
117
+ rpcById: Map<string, RpcCall>;
118
+ rpcProviderByMethod: Map<string, Service>;
119
+ externalById: Map<string, External>;
120
+ /**
121
+ * "<interface>/<method>" -> the external answering on it. Kept apart from
122
+ * `rpcProviderByMethod` rather than widened into it: every reader of that map
123
+ * follows the provider to a service page, and an external has none.
124
+ */
125
+ externalProviderByMethod: Map<string, External>;
126
+ storeById: Map<string, Store>;
127
+ /** table id -> the table and the store holding it */
128
+ tableById: Map<string, { table: Table; store: Store }>;
129
+ /** view id -> the view and the store declaring it */
130
+ viewById: Map<string, { view: View; store: Store }>;
131
+ /** column id -> the column and everything that owns it */
132
+ columnById: Map<string, ColumnOwner>;
133
+ /** column id -> the view column and everything that owns it */
134
+ viewColumnById: Map<string, ViewColumnOwner>;
135
+ /** table or view id -> the views reading it, in catalog order */
136
+ viewsReading: Map<string, View[]>;
137
+ /** column id -> the column ids it is computed from, in declaration order */
138
+ lineageFrom: Map<string, string[]>;
139
+ /** column id -> the column ids computed from it, in catalog order */
140
+ lineageInto: Map<string, string[]>;
141
+ /** service id -> stores it owns, in catalog order */
142
+ storesOwnedBy: Map<string, Store[]>;
143
+ /** aggregate id -> tables naming it in `persists`, in catalog order */
144
+ tablesByAggregate: Map<string, Table[]>;
145
+ /** aggregate id -> views naming it in `persists`, in catalog order */
146
+ viewsByAggregate: Map<string, View[]>;
147
+ /** aggregate id -> Redis key families holding it, in catalog order */
148
+ keyspacesByAggregate: Map<string, RedisKeyspaceOwner[]>;
149
+ /** block id -> columns whose `maps` path lands in that block */
150
+ columnsByBlock: Map<string, ColumnOwner[]>;
151
+ /** table id -> the columns pointing at it through a foreign key */
152
+ fkIntoTable: Map<string, ColumnOwner[]>;
153
+ flowBySlug: Map<string, Flow>;
154
+ /** event id -> flow slugs that reference it in a step */
155
+ flowsByEvent: Map<string, string[]>;
156
+ moduleById: Map<string, ProtoModule>;
157
+ moduleBySlug: Map<string, ProtoModule>;
158
+ /** module id -> the interfaces declaring themselves part of it, with their service */
159
+ interfacesByModule: Map<string, InterfaceOwner[]>;
160
+ /**
161
+ * module id -> services that publish it, vendor it, or name it on a call.
162
+ *
163
+ * The interesting fact about a module is usually who ELSE reads it, and no
164
+ * single field says so: a producer names it on an interface, a consumer on a
165
+ * call, and either may list it under `Service.modules`. One map answers it.
166
+ */
167
+ servicesUsingModule: Map<string, Service[]>;
168
+ adrById: Map<string, Adr>;
169
+ adrBySlug: Map<string, Adr>;
170
+ /** event id -> ADRs that name it in relates.events, newest first */
171
+ adrsByEvent: Map<string, Adr[]>;
172
+ termById: Map<string, Term>;
173
+ /** context id -> its vocabulary, alphabetical, as the glossary was written */
174
+ termsByContext: Map<string, Term[]>;
175
+ }
176
+
177
+ /** An interface and the service that answers on it. */
178
+ export interface InterfaceOwner {
179
+ service: Service;
180
+ provided: RpcService;
181
+ }
182
+
183
+ export interface RedisKeyspaceOwner {
184
+ keyspace: RedisKeyspace;
185
+ store: Store;
186
+ }
187
+
188
+ export function buildIndex(catalog: Catalog): CatalogIndex {
189
+ const serviceById = new Map<string, Service>();
190
+ const serviceContext = new Map<string, BoundedContext>();
191
+ const moduleById = new Map<string, ProtoModule>();
192
+ const moduleBySlug = new Map<string, ProtoModule>();
193
+ const interfacesByModule = new Map<string, InterfaceOwner[]>();
194
+ const servicesUsingModule = new Map<string, Service[]>();
195
+ const aggregateById = new Map<string, Aggregate>();
196
+ const aggregateOwner = new Map<string, Service>();
197
+ const eventById = new Map<string, Event>();
198
+ const eventByWireName = new Map<string, Event>();
199
+ const eventOwner = new Map<
200
+ string,
201
+ { service: Service; aggregate: Aggregate }
202
+ >();
203
+ const blockById = new Map<string, BlockOwner>();
204
+ const enumById = new Map<string, EnumOwner>();
205
+ const blocksByDef = new Map<string, string[]>();
206
+ const rpcById = new Map<string, RpcCall>();
207
+ const rpcProviderByMethod = new Map<string, Service>();
208
+ const externalById = new Map<string, External>();
209
+ const externalProviderByMethod = new Map<string, External>();
210
+ for (const external of allExternals(catalog)) {
211
+ externalById.set(external.id, external);
212
+ for (const provided of external.provides) {
213
+ for (const method of provided.methods) {
214
+ externalProviderByMethod.set(`${provided.id}/${method.name}`, external);
215
+ }
216
+ }
217
+ }
218
+ const flowBySlug = new Map<string, Flow>();
219
+ const flowsByEvent = new Map<string, string[]>();
220
+ const adrById = new Map<string, Adr>();
221
+ const adrBySlug = new Map<string, Adr>();
222
+ const adrsByEvent = new Map<string, Adr[]>();
223
+ const termById = new Map<string, Term>();
224
+ const termsByContext = new Map<string, Term[]>();
225
+ const storeById = new Map<string, Store>();
226
+ const tableById = new Map<string, { table: Table; store: Store }>();
227
+ const viewById = new Map<string, { view: View; store: Store }>();
228
+ const columnById = new Map<string, ColumnOwner>();
229
+ const viewColumnById = new Map<string, ViewColumnOwner>();
230
+ const viewsReading = new Map<string, View[]>();
231
+ const lineageFrom = new Map<string, string[]>();
232
+ const lineageInto = new Map<string, string[]>();
233
+ const storesOwnedBy = new Map<string, Store[]>();
234
+ const tablesByAggregate = new Map<string, Table[]>();
235
+ const viewsByAggregate = new Map<string, View[]>();
236
+ const keyspacesByAggregate = new Map<string, RedisKeyspaceOwner[]>();
237
+ const columnsByBlock = new Map<string, ColumnOwner[]>();
238
+ const fkIntoTable = new Map<string, ColumnOwner[]>();
239
+
240
+ for (const context of catalog.contexts) {
241
+ for (const service of context.services) {
242
+ serviceById.set(service.id, service);
243
+ serviceContext.set(service.id, context);
244
+ for (const call of service.consumes) rpcById.set(call.id, call);
245
+ for (const provided of service.provides) {
246
+ for (const method of provided.methods) {
247
+ rpcProviderByMethod.set(`${provided.id}/${method.name}`, service);
248
+ }
249
+ }
250
+ for (const aggregate of service.aggregates) {
251
+ aggregateById.set(aggregate.id, aggregate);
252
+ aggregateOwner.set(aggregate.id, service);
253
+ for (const event of aggregate.events) {
254
+ eventById.set(event.id, event);
255
+ eventOwner.set(event.id, { service, aggregate });
256
+ // First one wins, and two events sharing a wire name is a problem
257
+ // the Problems page is the place to say so about, not this.
258
+ if (event.wire && !eventByWireName.has(event.wire.name)) {
259
+ eventByWireName.set(event.wire.name, event);
260
+ }
261
+ }
262
+ for (const item of enumsOf(aggregate)) {
263
+ enumById.set(item.id, { enum: item, aggregate, service, context });
264
+ }
265
+ for (const { kind, block } of aggregateBlocks(aggregate)) {
266
+ blockById.set(block.id, { block, kind, aggregate, service, context });
267
+ if (block.ref) {
268
+ const list = blocksByDef.get(block.ref) ?? [];
269
+ list.push(block.id);
270
+ blocksByDef.set(block.ref, list);
271
+ }
272
+ }
273
+ }
274
+ }
275
+ }
276
+
277
+ // Lineage is recorded from the derived end, which is the only end that
278
+ // declares it, and both directions are kept: "where did this come from" and
279
+ // "who reads this" are asked as often as each other, and answering the
280
+ // second by scanning every column in the catalog is what an index is for.
281
+ const recordLineage = (id: string, column: Column): void => {
282
+ const sources = column.from ?? [];
283
+ if (sources.length === 0) return;
284
+ lineageFrom.set(id, [...sources]);
285
+ for (const source of sources) {
286
+ const list = lineageInto.get(source) ?? [];
287
+ if (!list.includes(id)) list.push(id);
288
+ lineageInto.set(source, list);
289
+ }
290
+ };
291
+
292
+ // Modules are collected from both ends: the top-level list says what exists,
293
+ // and the services say who touches it. A module named by a service the
294
+ // catalog has no entry for is refused by the validator, so nothing here has
295
+ // to guess.
296
+ for (const module of allModules(catalog)) {
297
+ moduleById.set(module.id, module);
298
+ moduleBySlug.set(module.slug, module);
299
+ }
300
+
301
+ const uses = (moduleId: string, service: Service) => {
302
+ const list = servicesUsingModule.get(moduleId) ?? [];
303
+ if (!list.includes(service)) list.push(service);
304
+ servicesUsingModule.set(moduleId, list);
305
+ };
306
+
307
+ for (const context of catalog.contexts) {
308
+ for (const service of context.services) {
309
+ for (const moduleId of service.modules ?? []) uses(moduleId, service);
310
+ for (const provided of service.provides) {
311
+ if (provided.module === undefined) continue;
312
+ const list = interfacesByModule.get(provided.module) ?? [];
313
+ list.push({ service, provided });
314
+ interfacesByModule.set(provided.module, list);
315
+ uses(provided.module, service);
316
+ }
317
+ for (const copy of service.copies ?? []) {
318
+ if (copy.module !== undefined) uses(copy.module, service);
319
+ }
320
+ for (const call of service.consumes) {
321
+ if (call.module !== undefined) uses(call.module, service);
322
+ }
323
+ }
324
+ }
325
+
326
+ // Stores come after the domain tree because they point into it: a table says
327
+ // which aggregate it persists, and a column which block it maps to, so both
328
+ // are resolved against maps that are already full.
329
+ for (const store of allStores(catalog)) {
330
+ storeById.set(store.id, store);
331
+ const owned = storesOwnedBy.get(store.owner) ?? [];
332
+ owned.push(store);
333
+ storesOwnedBy.set(store.owner, owned);
334
+
335
+ for (const keyspace of store.keyspaces ?? []) {
336
+ const aggregateId = keyspace.persists?.aggregate;
337
+ if (!aggregateId) continue;
338
+ const list = keyspacesByAggregate.get(aggregateId) ?? [];
339
+ list.push({ keyspace, store });
340
+ keyspacesByAggregate.set(aggregateId, list);
341
+ }
342
+
343
+ for (const table of store.tables) {
344
+ tableById.set(table.id, { table, store });
345
+ // A table may name only the block it holds. That block belongs to an
346
+ // aggregate, and an aggregate's Persistence section has to list it, so
347
+ // the owner is filled in here rather than asked for twice in the JSON.
348
+ const aggregateId =
349
+ table.persists?.aggregate ??
350
+ (table.persists?.block
351
+ ? blockById.get(table.persists.block)?.aggregate.id
352
+ : undefined);
353
+ if (aggregateId) {
354
+ const list = tablesByAggregate.get(aggregateId) ?? [];
355
+ list.push(table);
356
+ tablesByAggregate.set(aggregateId, list);
357
+ }
358
+ const aggregate = aggregateId
359
+ ? aggregateById.get(aggregateId)
360
+ : undefined;
361
+
362
+ for (const column of table.columns) {
363
+ const owner: ColumnOwner = { column, table, store };
364
+ columnById.set(columnId(table.id, column.name), owner);
365
+ recordLineage(columnId(table.id, column.name), column);
366
+ if (column.fk) {
367
+ const into = fkIntoTable.get(column.fk.table) ?? [];
368
+ into.push(owner);
369
+ fkIntoTable.set(column.fk.table, into);
370
+ }
371
+ // `persists.block` names the block outright; otherwise the head of the
372
+ // maps path is resolved inside the aggregate the table persists.
373
+ const blockId =
374
+ table.persists?.block ?? mapsBlockId(aggregate, column.maps);
375
+ if (blockId && column.maps) {
376
+ const list = columnsByBlock.get(blockId) ?? [];
377
+ list.push(owner);
378
+ columnsByBlock.set(blockId, list);
379
+ }
380
+ }
381
+ }
382
+
383
+ // Views after the tables of the same store: a view reads tables, and the
384
+ // ones it reads are usually its neighbours in the same file.
385
+ for (const view of storeViews(store)) {
386
+ viewById.set(view.id, { view, store });
387
+ const aggregateId =
388
+ view.persists?.aggregate ??
389
+ (view.persists?.block
390
+ ? blockById.get(view.persists.block)?.aggregate.id
391
+ : undefined);
392
+ if (aggregateId) {
393
+ const list = viewsByAggregate.get(aggregateId) ?? [];
394
+ list.push(view);
395
+ viewsByAggregate.set(aggregateId, list);
396
+ }
397
+ for (const readId of viewReads(view)) {
398
+ const list = viewsReading.get(readId) ?? [];
399
+ if (!list.includes(view)) list.push(view);
400
+ viewsReading.set(readId, list);
401
+ }
402
+ for (const column of view.columns) {
403
+ const id = columnId(view.id, column.name);
404
+ viewColumnById.set(id, { column, view, store });
405
+ recordLineage(id, column);
406
+ }
407
+ }
408
+ }
409
+
410
+ for (const flow of catalog.flows) {
411
+ flowBySlug.set(flow.slug, flow);
412
+ for (const step of walkSteps(flow.steps)) {
413
+ if (step.ref && eventById.has(step.ref)) {
414
+ const list = flowsByEvent.get(step.ref) ?? [];
415
+ if (!list.includes(flow.slug)) list.push(flow.slug);
416
+ flowsByEvent.set(step.ref, list);
417
+ }
418
+ }
419
+ }
420
+
421
+ for (const adr of [...catalog.adrs].sort(byDateDesc)) {
422
+ adrById.set(adr.id, adr);
423
+ adrBySlug.set(adr.slug, adr);
424
+ for (const eventId of adr.relates.events ?? []) {
425
+ const list = adrsByEvent.get(eventId) ?? [];
426
+ list.push(adr);
427
+ adrsByEvent.set(eventId, list);
428
+ }
429
+ }
430
+
431
+ for (const term of allTerms(catalog)) {
432
+ termById.set(term.id, term);
433
+ const list = termsByContext.get(term.context) ?? [];
434
+ list.push(term);
435
+ termsByContext.set(term.context, list);
436
+ }
437
+
438
+ return {
439
+ catalog,
440
+ serviceById,
441
+ serviceContext,
442
+ aggregateById,
443
+ aggregateOwner,
444
+ eventById,
445
+ eventOwner,
446
+ eventByWireName,
447
+ blockById,
448
+ enumById,
449
+ blocksByDef,
450
+ rpcById,
451
+ rpcProviderByMethod,
452
+ externalById,
453
+ externalProviderByMethod,
454
+ flowBySlug,
455
+ flowsByEvent,
456
+ adrById,
457
+ adrBySlug,
458
+ adrsByEvent,
459
+ termById,
460
+ termsByContext,
461
+ storeById,
462
+ tableById,
463
+ viewById,
464
+ columnById,
465
+ viewColumnById,
466
+ viewsReading,
467
+ lineageFrom,
468
+ lineageInto,
469
+ storesOwnedBy,
470
+ moduleById,
471
+ moduleBySlug,
472
+ interfacesByModule,
473
+ servicesUsingModule,
474
+ tablesByAggregate,
475
+ viewsByAggregate,
476
+ keyspacesByAggregate,
477
+ columnsByBlock,
478
+ fkIntoTable,
479
+ };
480
+ }
481
+
482
+ /** Newest decision first; ties broken by number so the order is total. */
483
+ export function byDateDesc(a: Adr, b: Adr): number {
484
+ return b.date.localeCompare(a.date) || b.number - a.number;
485
+ }