@entifix/next-shell 0.4.2 → 0.6.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 (120) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.js +1 -1
  3. package/dist/index.js.map +1 -1
  4. package/dist/lib/back-office/group-by-screen-type.d.ts +2 -2
  5. package/dist/lib/back-office/group-by-screen-type.js +2 -2
  6. package/dist/lib/back-office/group-by-screen-type.js.map +1 -1
  7. package/dist/lib/back-office/nav.d.ts +1 -2
  8. package/dist/lib/back-office/nav.d.ts.map +1 -1
  9. package/dist/lib/back-office/nav.js.map +1 -1
  10. package/dist/lib/back-office/sidebar-nav.d.ts +2 -2
  11. package/dist/lib/back-office/sidebar-nav.js +2 -2
  12. package/dist/lib/back-office/sidebar-nav.js.map +1 -1
  13. package/dist/lib/command-palette/command-palette-host.js +1 -1
  14. package/dist/lib/command-palette/command-palette-host.js.map +1 -1
  15. package/dist/lib/command-palette/use-command-recency.d.ts +1 -1
  16. package/dist/lib/command-palette/use-command-recency.js +1 -1
  17. package/dist/lib/command-palette/use-command-recency.js.map +1 -1
  18. package/dist/lib/command-palette/use-nav-source.d.ts +4 -3
  19. package/dist/lib/command-palette/use-nav-source.d.ts.map +1 -1
  20. package/dist/lib/command-palette/use-nav-source.js +4 -3
  21. package/dist/lib/command-palette/use-nav-source.js.map +1 -1
  22. package/dist/lib/command-palette/use-records-source.d.ts +1 -1
  23. package/dist/lib/command-palette/use-records-source.js +2 -2
  24. package/dist/lib/command-palette/use-records-source.js.map +1 -1
  25. package/dist/lib/command-palette/use-tabs-source.d.ts +1 -1
  26. package/dist/lib/command-palette/use-tabs-source.js +1 -1
  27. package/dist/lib/command-palette/use-tabs-source.js.map +1 -1
  28. package/dist/lib/command-palette/use-use-cases-source.js +2 -2
  29. package/dist/lib/command-palette/use-use-cases-source.js.map +1 -1
  30. package/dist/lib/config/config-route.d.ts +6 -19
  31. package/dist/lib/config/config-route.d.ts.map +1 -1
  32. package/dist/lib/config/config-route.js +6 -23
  33. package/dist/lib/config/config-route.js.map +1 -1
  34. package/dist/lib/config/configuration-source.d.ts +18 -0
  35. package/dist/lib/config/configuration-source.d.ts.map +1 -0
  36. package/dist/lib/config/configuration-source.js +15 -0
  37. package/dist/lib/config/configuration-source.js.map +1 -0
  38. package/dist/lib/config/service-proxy-route.d.ts +11 -2
  39. package/dist/lib/config/service-proxy-route.d.ts.map +1 -1
  40. package/dist/lib/config/service-proxy-route.js +27 -4
  41. package/dist/lib/config/service-proxy-route.js.map +1 -1
  42. package/dist/lib/crud/entity-crud-form.js +3 -3
  43. package/dist/lib/crud/entity-crud-form.js.map +1 -1
  44. package/dist/lib/crud/hand-off-write.d.ts +1 -1
  45. package/dist/lib/crud/hand-off-write.js +1 -1
  46. package/dist/lib/crud/hand-off-write.js.map +1 -1
  47. package/dist/lib/crud/make-entity-crud.d.ts +7 -9
  48. package/dist/lib/crud/make-entity-crud.d.ts.map +1 -1
  49. package/dist/lib/crud/make-entity-crud.js +15 -18
  50. package/dist/lib/crud/make-entity-crud.js.map +1 -1
  51. package/dist/lib/crud/make-entity-crud.types.d.ts +9 -12
  52. package/dist/lib/crud/make-entity-crud.types.d.ts.map +1 -1
  53. package/dist/lib/crud/make-entity-crud.types.js.map +1 -1
  54. package/dist/lib/crud/pending-notice.d.ts +2 -2
  55. package/dist/lib/crud/pending-notice.js +2 -2
  56. package/dist/lib/crud/pending-notice.js.map +1 -1
  57. package/dist/lib/crud/use-entity-affordances.d.ts +1 -1
  58. package/dist/lib/crud/use-entity-affordances.js +1 -1
  59. package/dist/lib/crud/use-entity-affordances.js.map +1 -1
  60. package/dist/lib/crud/use-entity-bulk.d.ts +2 -2
  61. package/dist/lib/crud/use-entity-bulk.js +2 -2
  62. package/dist/lib/crud/use-entity-bulk.js.map +1 -1
  63. package/dist/lib/health/health-routes.d.ts +3 -4
  64. package/dist/lib/health/health-routes.d.ts.map +1 -1
  65. package/dist/lib/health/health-routes.js +7 -9
  66. package/dist/lib/health/health-routes.js.map +1 -1
  67. package/dist/lib/i18n/catalog/en.d.ts.map +1 -1
  68. package/dist/lib/i18n/catalog/en.js +1 -264
  69. package/dist/lib/i18n/catalog/en.js.map +1 -1
  70. package/dist/lib/i18n/catalog/es.d.ts +2 -290
  71. package/dist/lib/i18n/catalog/es.d.ts.map +1 -1
  72. package/dist/lib/i18n/catalog/es.js +2 -296
  73. package/dist/lib/i18n/catalog/es.js.map +1 -1
  74. package/dist/lib/i18n/catalog/index.d.ts +0 -508
  75. package/dist/lib/i18n/catalog/index.d.ts.map +1 -1
  76. package/dist/lib/i18n/spec-catalog.js +4 -4
  77. package/dist/lib/i18n/spec-catalog.js.map +1 -1
  78. package/dist/lib/search/record-search-route.d.ts +1 -1
  79. package/dist/lib/search/record-search-route.js +1 -1
  80. package/dist/lib/search/record-search-route.js.map +1 -1
  81. package/dist/lib/search/record-search-source.d.ts +1 -2
  82. package/dist/lib/search/record-search-source.d.ts.map +1 -1
  83. package/dist/lib/search/record-search-source.js.map +1 -1
  84. package/dist/lib/search/record-search.types.d.ts +1 -1
  85. package/dist/lib/search/record-search.types.js +1 -1
  86. package/dist/lib/search/record-search.types.js.map +1 -1
  87. package/dist/lib/session/account-links.js +1 -1
  88. package/dist/lib/session/account-links.js.map +1 -1
  89. package/dist/lib/session/session-keepalive.d.ts +2 -4
  90. package/dist/lib/session/session-keepalive.d.ts.map +1 -1
  91. package/dist/lib/session/session-keepalive.js +2 -4
  92. package/dist/lib/session/session-keepalive.js.map +1 -1
  93. package/dist/lib/session/use-session-refresh.js +1 -1
  94. package/dist/lib/session/use-session-refresh.js.map +1 -1
  95. package/dist/lib/wizard/wizard-tab-kind.d.ts +1 -2
  96. package/dist/lib/wizard/wizard-tab-kind.d.ts.map +1 -1
  97. package/dist/lib/wizard/wizard-tab-kind.js +1 -2
  98. package/dist/lib/wizard/wizard-tab-kind.js.map +1 -1
  99. package/dist/lib/workspace/drafts-state.d.ts.map +1 -1
  100. package/dist/lib/workspace/drafts-state.js +3 -2
  101. package/dist/lib/workspace/drafts-state.js.map +1 -1
  102. package/dist/lib/workspace/entity-tab-kind.d.ts +1 -2
  103. package/dist/lib/workspace/entity-tab-kind.d.ts.map +1 -1
  104. package/dist/lib/workspace/entity-tab-kind.js +1 -2
  105. package/dist/lib/workspace/entity-tab-kind.js.map +1 -1
  106. package/dist/lib/workspace/pending-state.d.ts +5 -4
  107. package/dist/lib/workspace/pending-state.d.ts.map +1 -1
  108. package/dist/lib/workspace/pending-state.js +1 -1
  109. package/dist/lib/workspace/pending-state.js.map +1 -1
  110. package/dist/lib/workspace/tabs-state.js +1 -1
  111. package/dist/lib/workspace/tabs-state.js.map +1 -1
  112. package/dist/server.d.ts +1 -1
  113. package/dist/server.d.ts.map +1 -1
  114. package/dist/server.js +2 -2
  115. package/dist/server.js.map +1 -1
  116. package/package.json +9 -9
  117. package/dist/lib/config/service-token.d.ts +0 -5
  118. package/dist/lib/config/service-token.d.ts.map +0 -1
  119. package/dist/lib/config/service-token.js +0 -18
  120. package/dist/lib/config/service-token.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/lib/search/record-search-source.ts"],"sourcesContent":["import {\n describeEntityColumns,\n EntifixLogicError,\n type Entity,\n type EntityConstructor,\n type EntityFieldDescriptor,\n type EntityLoadRequest,\n envelopeEntityName,\n readEntityPageEnvelope,\n serializeLoadRequestParams,\n} from '@entifix/core';\nimport { Effect, Exit } from 'effect';\n\nimport type { RecordSearchOption } from './record-search.types.js';\n\n/**\n * What a host declares in order to make one entity searchable from the palette\n * (ADR 0040).\n *\n * The search and label members are **declared**, not derived, and that is not an\n * oversight in the metadata: `linkSearchProperty`/`linkLabelProperty` are\n * properties of a *referring* accessor — they say how the owner of a relation\n * looks up its target — and an entity has no member that says \"this is what I am\n * called\". So the host that mounts the screens says it, next to the route those\n * screens live at.\n */\nexport interface RecordSearchSourceConfig<TEntity extends Entity> {\n /** Stable id, unique per host. Defaults to the entity's wire name. */\n readonly key?: string;\n readonly entityConstructor: EntityConstructor<TEntity>;\n /** Where the owning service listens, **without** the `/api` suffix. */\n readonly baseUrl: string;\n /** The `filterable` string member a term is matched against with `like`. */\n readonly searchProperty: string;\n /** The `sortable` member read as a record's label. */\n readonly labelProperty: string;\n /** An optional secondary line — a code, a role. */\n readonly sublabelProperty?: string;\n /** The group heading, as the entity's own `pluralKey`. */\n readonly labelKey: string;\n /** Where selecting a record lands. Locale-free; the caller prefixes it. */\n readonly href: (id: string) => string;\n}\n\n/** A declared source, reduced to what the fan-out actually calls. */\nexport interface RecordSearchSource {\n readonly key: string;\n readonly entity: string;\n readonly labelKey: string;\n /** The upstream URL for one term. */\n readonly url: (term: string, limit: number) => string;\n /** `undefined` when the body is not a readable `entityPage` envelope. */\n readonly read: (\n body: unknown,\n ) => { items: RecordSearchOption[]; total: number } | undefined;\n}\n\nconst descriptorOf = (\n columns: EntityFieldDescriptor[],\n name: string,\n): EntityFieldDescriptor | undefined =>\n columns.find(column => column.name === name);\n\nconst rejection = (\n entityName: string,\n member: string,\n because: string,\n fix: string,\n): EntifixLogicError =>\n new EntifixLogicError(\n `Cannot search ${entityName} by \"${member}\": ${because} ${fix}`,\n undefined,\n { entity: entityName, member },\n );\n\n/**\n * Declare a record search source, validating it against the entity's own\n * metadata **now** rather than on the first keystroke.\n *\n * Every check here guards a failure that is otherwise silent at both ends: the\n * service answers `400`, and a caller renders that as a group it could not\n * reach — which reads as \"there are no products\". `assertSearchable` took the\n * same posture for the relation picker, for the same reason.\n *\n * ⚠️ This throws at **module load**, so a bad declaration fails the whole app at\n * boot rather than just search. That is deliberate — a source silently missing\n * from a palette is indistinguishable from a permission the caller lacks — but\n * the blast radius is wider than the picker's, which fails only its own render.\n */\nexport const defineRecordSearchSource = <TEntity extends Entity>({\n key,\n entityConstructor,\n baseUrl,\n searchProperty,\n labelProperty,\n sublabelProperty,\n labelKey,\n href,\n}: RecordSearchSourceConfig<TEntity>): RecordSearchSource => {\n const entity = envelopeEntityName(entityConstructor);\n const entityName = entityConstructor.name;\n const columns = describeEntityColumns(entityConstructor);\n\n const search = descriptorOf(columns, searchProperty);\n if (search === undefined) {\n throw rejection(\n entityName,\n searchProperty,\n 'the entity declares no such member.',\n 'Name a member the entity actually has.',\n );\n }\n // `filterable` is simultaneously the server-side RSQL allowlist.\n if (!search.filterable) {\n throw rejection(\n entityName,\n searchProperty,\n 'the member is not filterable, so the service would reject the query.',\n 'Declare it `filterable` on the entity, or search another member.',\n );\n }\n // A `like` against anything else reaches `coerceValue`, which rejects a\n // partial term — so an enum member reads as declarable and is permanently\n // broken: every keystroke short of a whole value answers `400`.\n if (search.type !== 'string') {\n throw rejection(\n entityName,\n searchProperty,\n `the member is a ${search.type}, and a partial term is not a valid value for it.`,\n 'Search a string member instead.',\n );\n }\n\n const label = descriptorOf(columns, labelProperty);\n if (label === undefined) {\n throw rejection(\n entityName,\n labelProperty,\n 'the entity declares no such label member.',\n 'Name a member the entity actually has.',\n );\n }\n // The group is sorted by the label, so the flag is load-bearing: without it\n // the service rejects the sort and the group degrades whole. Unsorted was the\n // alternative and is worse — the services apply no default order, so results\n // would arrive in storage order and shuffle as records are rewritten.\n // A localized label sorts one locale at a time, and a search that fans out\n // across services holds none: the bare member would answer `400`.\n if (label.type === 'localized') {\n throw rejection(\n entityName,\n labelProperty,\n 'the label member is localized, and the search has no locale to sort it in.',\n 'Label records by a string member.',\n );\n }\n if (!label.sortable) {\n throw rejection(\n entityName,\n labelProperty,\n 'the label member is not sortable, so the service would reject the sort.',\n 'Declare it `sortable` on the entity, or label records by another member.',\n );\n }\n\n let sublabel: EntityFieldDescriptor | undefined;\n if (sublabelProperty !== undefined) {\n sublabel = descriptorOf(columns, sublabelProperty);\n if (sublabel === undefined) {\n throw rejection(\n entityName,\n sublabelProperty,\n 'the entity declares no such sublabel member.',\n 'Name a member the entity actually has, or drop `sublabelProperty`.',\n );\n }\n }\n\n const read = (\n body: unknown,\n ): { items: RecordSearchOption[]; total: number } | undefined => {\n const page = Effect.runSyncExit(\n readEntityPageEnvelope(entityConstructor, body),\n );\n if (Exit.isFailure(page)) return undefined;\n\n const items = page.value.items\n .filter(record => (record as Entity).id != null)\n .map(record => {\n const row = record as unknown as Record<string, unknown>;\n const raw = row[labelProperty];\n const id = String((record as Entity).id);\n return {\n id,\n // A blank label is as useless as a missing one, so both fall back to\n // the id — an unnamed record must stay distinguishable from the one\n // above it. Same rule the relation picker's `labelOf` applies.\n label: raw == null || raw === '' ? id : String(raw),\n ...(sublabel !== undefined && row[sublabel.name] != null\n ? { sublabel: String(row[sublabel.name]) }\n : {}),\n entity,\n href: href(id),\n } satisfies RecordSearchOption;\n });\n\n return { items, total: page.value.total };\n };\n\n return {\n key: key ?? entity,\n entity,\n labelKey,\n /**\n * Built through `serializeLoadRequestParams`, never by hand. That is\n * correctness rather than tidiness: `encodeRsqlValue` quotes a term carrying\n * any of `;,()'\"=!<>\\` or whitespace, so `Acme, Inc.` stays one value\n * instead of splitting the expression into two comparisons.\n */\n url: (term, limit) => {\n const request = {\n filtering: [\n {\n property: search.key,\n operator: 'like' as const,\n value: term,\n },\n ],\n sorting: [{ 0: { property: label.key, type: 'asc' as const } }],\n page: 1,\n pageSize: limit,\n } as unknown as EntityLoadRequest;\n\n return `${baseUrl}/api/${entity}?${serializeLoadRequestParams(request).toString()}`;\n },\n read,\n };\n};\n"],"names":["describeEntityColumns","EntifixLogicError","envelopeEntityName","readEntityPageEnvelope","serializeLoadRequestParams","Effect","Exit","descriptorOf","columns","name","find","column","rejection","entityName","member","because","fix","undefined","entity","defineRecordSearchSource","key","entityConstructor","baseUrl","searchProperty","labelProperty","sublabelProperty","labelKey","href","search","filterable","type","label","sortable","sublabel","read","body","page","runSyncExit","isFailure","items","value","filter","record","id","map","row","raw","String","total","url","term","limit","request","filtering","property","operator","sorting","pageSize","toString"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../src/lib/search/record-search-source.ts"],"sourcesContent":["import {\n describeEntityColumns,\n EntifixLogicError,\n type Entity,\n type EntityConstructor,\n type EntityFieldDescriptor,\n type EntityLoadRequest,\n envelopeEntityName,\n readEntityPageEnvelope,\n serializeLoadRequestParams,\n} from '@entifix/core';\nimport { Effect, Exit } from 'effect';\n\nimport type { RecordSearchOption } from './record-search.types.js';\n\n/**\n * What a host declares in order to make one entity searchable from the palette.\n *\n * The search and label members are **declared**, not derived, and that is not an\n * oversight in the metadata: `linkSearchProperty`/`linkLabelProperty` are\n * properties of a *referring* accessor — they say how the owner of a relation\n * looks up its target — and an entity has no member that says \"this is what I am\n * called\". So the host that mounts the screens says it, next to the route those\n * screens live at.\n */\nexport interface RecordSearchSourceConfig<TEntity extends Entity> {\n /** Stable id, unique per host. Defaults to the entity's wire name. */\n readonly key?: string;\n readonly entityConstructor: EntityConstructor<TEntity>;\n /** Where the owning service listens, **without** the `/api` suffix. */\n readonly baseUrl: string;\n /** The `filterable` string member a term is matched against with `like`. */\n readonly searchProperty: string;\n /** The `sortable` member read as a record's label. */\n readonly labelProperty: string;\n /** An optional secondary line — a code, a role. */\n readonly sublabelProperty?: string;\n /** The group heading, as the entity's own `pluralKey`. */\n readonly labelKey: string;\n /** Where selecting a record lands. Locale-free; the caller prefixes it. */\n readonly href: (id: string) => string;\n}\n\n/** A declared source, reduced to what the fan-out actually calls. */\nexport interface RecordSearchSource {\n readonly key: string;\n readonly entity: string;\n readonly labelKey: string;\n /** The upstream URL for one term. */\n readonly url: (term: string, limit: number) => string;\n /** `undefined` when the body is not a readable `entityPage` envelope. */\n readonly read: (\n body: unknown,\n ) => { items: RecordSearchOption[]; total: number } | undefined;\n}\n\nconst descriptorOf = (\n columns: EntityFieldDescriptor[],\n name: string,\n): EntityFieldDescriptor | undefined =>\n columns.find(column => column.name === name);\n\nconst rejection = (\n entityName: string,\n member: string,\n because: string,\n fix: string,\n): EntifixLogicError =>\n new EntifixLogicError(\n `Cannot search ${entityName} by \"${member}\": ${because} ${fix}`,\n undefined,\n { entity: entityName, member },\n );\n\n/**\n * Declare a record search source, validating it against the entity's own\n * metadata **now** rather than on the first keystroke.\n *\n * Every check here guards a failure that is otherwise silent at both ends: the\n * service answers `400`, and a caller renders that as a group it could not\n * reach — which reads as \"there are no products\". `assertSearchable` took the\n * same posture for the relation picker, for the same reason.\n *\n * ⚠️ This throws at **module load**, so a bad declaration fails the whole app at\n * boot rather than just search. That is deliberate — a source silently missing\n * from a palette is indistinguishable from a permission the caller lacks — but\n * the blast radius is wider than the picker's, which fails only its own render.\n */\nexport const defineRecordSearchSource = <TEntity extends Entity>({\n key,\n entityConstructor,\n baseUrl,\n searchProperty,\n labelProperty,\n sublabelProperty,\n labelKey,\n href,\n}: RecordSearchSourceConfig<TEntity>): RecordSearchSource => {\n const entity = envelopeEntityName(entityConstructor);\n const entityName = entityConstructor.name;\n const columns = describeEntityColumns(entityConstructor);\n\n const search = descriptorOf(columns, searchProperty);\n if (search === undefined) {\n throw rejection(\n entityName,\n searchProperty,\n 'the entity declares no such member.',\n 'Name a member the entity actually has.',\n );\n }\n // `filterable` is simultaneously the server-side RSQL allowlist.\n if (!search.filterable) {\n throw rejection(\n entityName,\n searchProperty,\n 'the member is not filterable, so the service would reject the query.',\n 'Declare it `filterable` on the entity, or search another member.',\n );\n }\n // A `like` against anything else reaches `coerceValue`, which rejects a\n // partial term — so an enum member reads as declarable and is permanently\n // broken: every keystroke short of a whole value answers `400`.\n if (search.type !== 'string') {\n throw rejection(\n entityName,\n searchProperty,\n `the member is a ${search.type}, and a partial term is not a valid value for it.`,\n 'Search a string member instead.',\n );\n }\n\n const label = descriptorOf(columns, labelProperty);\n if (label === undefined) {\n throw rejection(\n entityName,\n labelProperty,\n 'the entity declares no such label member.',\n 'Name a member the entity actually has.',\n );\n }\n // The group is sorted by the label, so the flag is load-bearing: without it\n // the service rejects the sort and the group degrades whole. Unsorted was the\n // alternative and is worse — the services apply no default order, so results\n // would arrive in storage order and shuffle as records are rewritten.\n // A localized label sorts one locale at a time, and a search that fans out\n // across services holds none: the bare member would answer `400`.\n if (label.type === 'localized') {\n throw rejection(\n entityName,\n labelProperty,\n 'the label member is localized, and the search has no locale to sort it in.',\n 'Label records by a string member.',\n );\n }\n if (!label.sortable) {\n throw rejection(\n entityName,\n labelProperty,\n 'the label member is not sortable, so the service would reject the sort.',\n 'Declare it `sortable` on the entity, or label records by another member.',\n );\n }\n\n let sublabel: EntityFieldDescriptor | undefined;\n if (sublabelProperty !== undefined) {\n sublabel = descriptorOf(columns, sublabelProperty);\n if (sublabel === undefined) {\n throw rejection(\n entityName,\n sublabelProperty,\n 'the entity declares no such sublabel member.',\n 'Name a member the entity actually has, or drop `sublabelProperty`.',\n );\n }\n }\n\n const read = (\n body: unknown,\n ): { items: RecordSearchOption[]; total: number } | undefined => {\n const page = Effect.runSyncExit(\n readEntityPageEnvelope(entityConstructor, body),\n );\n if (Exit.isFailure(page)) return undefined;\n\n const items = page.value.items\n .filter(record => (record as Entity).id != null)\n .map(record => {\n const row = record as unknown as Record<string, unknown>;\n const raw = row[labelProperty];\n const id = String((record as Entity).id);\n return {\n id,\n // A blank label is as useless as a missing one, so both fall back to\n // the id — an unnamed record must stay distinguishable from the one\n // above it. Same rule the relation picker's `labelOf` applies.\n label: raw == null || raw === '' ? id : String(raw),\n ...(sublabel !== undefined && row[sublabel.name] != null\n ? { sublabel: String(row[sublabel.name]) }\n : {}),\n entity,\n href: href(id),\n } satisfies RecordSearchOption;\n });\n\n return { items, total: page.value.total };\n };\n\n return {\n key: key ?? entity,\n entity,\n labelKey,\n /**\n * Built through `serializeLoadRequestParams`, never by hand. That is\n * correctness rather than tidiness: `encodeRsqlValue` quotes a term carrying\n * any of `;,()'\"=!<>\\` or whitespace, so `Acme, Inc.` stays one value\n * instead of splitting the expression into two comparisons.\n */\n url: (term, limit) => {\n const request = {\n filtering: [\n {\n property: search.key,\n operator: 'like' as const,\n value: term,\n },\n ],\n sorting: [{ 0: { property: label.key, type: 'asc' as const } }],\n page: 1,\n pageSize: limit,\n } as unknown as EntityLoadRequest;\n\n return `${baseUrl}/api/${entity}?${serializeLoadRequestParams(request).toString()}`;\n },\n read,\n };\n};\n"],"names":["describeEntityColumns","EntifixLogicError","envelopeEntityName","readEntityPageEnvelope","serializeLoadRequestParams","Effect","Exit","descriptorOf","columns","name","find","column","rejection","entityName","member","because","fix","undefined","entity","defineRecordSearchSource","key","entityConstructor","baseUrl","searchProperty","labelProperty","sublabelProperty","labelKey","href","search","filterable","type","label","sortable","sublabel","read","body","page","runSyncExit","isFailure","items","value","filter","record","id","map","row","raw","String","total","url","term","limit","request","filtering","property","operator","sorting","pageSize","toString"],"mappings":"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"}
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The wire contract of `GET /api/search` — the cross-domain record search the
3
- * command palette reads (ADR 0040).
3
+ * command palette reads.
4
4
  *
5
5
  * Types only, so both halves can name them: the route handler builds these on
6
6
  * the server, and the browser's `searchRecords` returns them. A type crosses the
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The wire contract of `GET /api/search` — the cross-domain record search the
3
- * command palette reads (ADR 0040).
3
+ * command palette reads.
4
4
  *
5
5
  * Types only, so both halves can name them: the route handler builds these on
6
6
  * the server, and the browser's `searchRecords` returns them. A type crosses the
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/lib/search/record-search.types.ts"],"sourcesContent":["/**\n * The wire contract of `GET /api/search` — the cross-domain record search the\n * command palette reads (ADR 0040).\n *\n * Types only, so both halves can name them: the route handler builds these on\n * the server, and the browser's `searchRecords` returns them. A type crosses the\n * client/server entry split freely because it erases.\n */\n\n/**\n * Why a source produced no group.\n *\n * The split is the point, and it is not cosmetic. `forbidden` and\n * `noActiveOrganization` are the **normal** state for some callers on some\n * sources — an operator holds no membership, so every tenant-plane search\n * answers `409` — and rendering \"we could not reach this\" for the ordinary case\n * teaches an operator to ignore the warning. `timeout`/`network`/`unexpected`\n * are the ones that mean something is actually wrong.\n *\n * Every member is a key in the shared `errors` catalog, so a caller renders one\n * through the documented runtime-key hatch rather than inventing copy.\n */\nexport type RecordSearchUnavailableReason =\n | 'timeout'\n | 'network'\n | 'unauthenticated'\n | 'forbidden'\n | 'noActiveOrganization'\n | 'invalidQuery'\n | 'notFound'\n | 'unexpected';\n\n/** One record a caller can show and route to. */\nexport interface RecordSearchOption {\n readonly id: string;\n readonly label: string;\n /** A secondary line — a code, a role. Absent when the source declared none. */\n readonly sublabel?: string;\n /**\n * `envelopeEntityName(Ctor)` — `key ?? name`.\n *\n * Repeated on every option rather than left to the group, because an option is\n * routed on its own once a caller has flattened the list: `['entity', entity]`\n * is exactly `entityQueryScope`'s output, and `entity:<entity>:<id>` is the\n * workspace tab address.\n */\n readonly entity: string;\n /**\n * Where selecting this record lands, **without** a locale prefix — the caller\n * adds one, the same way every other internal href in the back office works.\n */\n readonly href: string;\n}\n\n/** One source's answer. */\nexport interface RecordSearchGroup {\n /** The source's declared key. Stable, and the caller's ordering. */\n readonly source: string;\n readonly entity: string;\n /**\n * The heading, as the entity's own `pluralKey`. A key rather than copy,\n * because the server has no locale — and the entity catalogs already carry one\n * for every source, so record search introduces no copy of its own.\n */\n readonly labelKey: string;\n readonly items: readonly RecordSearchOption[];\n /**\n * Matches **before** the per-group limit, so \"3 more\" is renderable. A\n * reachable source with no matches is a present group with an empty `items`,\n * never an omission — that distinction is the whole reason\n * {@link RecordSearchResponse.unavailable} exists.\n */\n readonly total: number;\n}\n\n/** One source that produced nothing, and why. */\nexport interface RecordSearchUnavailable {\n readonly source: string;\n readonly entity: string;\n readonly reason: RecordSearchUnavailableReason;\n /** The upstream status, when there was a response. Absent on a throw. */\n readonly status?: number;\n}\n\n/**\n * The whole answer.\n *\n * A degraded source is **named**, never a silently missing group. An empty group\n * is a confident claim that there are no matching records, and a search that\n * confidently reports nothing when it in fact could not look is the failure\n * mode this shape exists to prevent.\n */\nexport interface RecordSearchResponse {\n readonly term: string;\n readonly groups: readonly RecordSearchGroup[];\n readonly unavailable: readonly RecordSearchUnavailable[];\n}\n"],"names":[],"mappings":"AAAA;;;;;;;CAOC,GAED;;;;;;;;;;;;CAYC,GA+DD;;;;;;;CAOC,GACD,WAIC"}
1
+ {"version":3,"sources":["../../../src/lib/search/record-search.types.ts"],"sourcesContent":["/**\n * The wire contract of `GET /api/search` — the cross-domain record search the\n * command palette reads.\n *\n * Types only, so both halves can name them: the route handler builds these on\n * the server, and the browser's `searchRecords` returns them. A type crosses the\n * client/server entry split freely because it erases.\n */\n\n/**\n * Why a source produced no group.\n *\n * The split is the point, and it is not cosmetic. `forbidden` and\n * `noActiveOrganization` are the **normal** state for some callers on some\n * sources — an operator holds no membership, so every tenant-plane search\n * answers `409` — and rendering \"we could not reach this\" for the ordinary case\n * teaches an operator to ignore the warning. `timeout`/`network`/`unexpected`\n * are the ones that mean something is actually wrong.\n *\n * Every member is a key in the shared `errors` catalog, so a caller renders one\n * through the documented runtime-key hatch rather than inventing copy.\n */\nexport type RecordSearchUnavailableReason =\n | 'timeout'\n | 'network'\n | 'unauthenticated'\n | 'forbidden'\n | 'noActiveOrganization'\n | 'invalidQuery'\n | 'notFound'\n | 'unexpected';\n\n/** One record a caller can show and route to. */\nexport interface RecordSearchOption {\n readonly id: string;\n readonly label: string;\n /** A secondary line — a code, a role. Absent when the source declared none. */\n readonly sublabel?: string;\n /**\n * `envelopeEntityName(Ctor)` — `key ?? name`.\n *\n * Repeated on every option rather than left to the group, because an option is\n * routed on its own once a caller has flattened the list: `['entity', entity]`\n * is exactly `entityQueryScope`'s output, and `entity:<entity>:<id>` is the\n * workspace tab address.\n */\n readonly entity: string;\n /**\n * Where selecting this record lands, **without** a locale prefix — the caller\n * adds one, the same way every other internal href in the back office works.\n */\n readonly href: string;\n}\n\n/** One source's answer. */\nexport interface RecordSearchGroup {\n /** The source's declared key. Stable, and the caller's ordering. */\n readonly source: string;\n readonly entity: string;\n /**\n * The heading, as the entity's own `pluralKey`. A key rather than copy,\n * because the server has no locale — and the entity catalogs already carry one\n * for every source, so record search introduces no copy of its own.\n */\n readonly labelKey: string;\n readonly items: readonly RecordSearchOption[];\n /**\n * Matches **before** the per-group limit, so \"3 more\" is renderable. A\n * reachable source with no matches is a present group with an empty `items`,\n * never an omission — that distinction is the whole reason\n * {@link RecordSearchResponse.unavailable} exists.\n */\n readonly total: number;\n}\n\n/** One source that produced nothing, and why. */\nexport interface RecordSearchUnavailable {\n readonly source: string;\n readonly entity: string;\n readonly reason: RecordSearchUnavailableReason;\n /** The upstream status, when there was a response. Absent on a throw. */\n readonly status?: number;\n}\n\n/**\n * The whole answer.\n *\n * A degraded source is **named**, never a silently missing group. An empty group\n * is a confident claim that there are no matching records, and a search that\n * confidently reports nothing when it in fact could not look is the failure\n * mode this shape exists to prevent.\n */\nexport interface RecordSearchResponse {\n readonly term: string;\n readonly groups: readonly RecordSearchGroup[];\n readonly unavailable: readonly RecordSearchUnavailable[];\n}\n"],"names":[],"mappings":"AAAA;;;;;;;CAOC,GAED;;;;;;;;;;;;CAYC,GA+DD;;;;;;;CAOC,GACD,WAIC"}
@@ -13,7 +13,7 @@
13
13
  labelKey: 'account.profile',
14
14
  path: '/account'
15
15
  },
16
- // Not `/account/password` any more. r10c holds no password to change, so the
16
+ // Not `/account/password` any more. The host holds no password to change, so the
17
17
  // screen behind this is a set of links into the provider's own self-service —
18
18
  // which is also where a second factor is enrolled and a social account is
19
19
  // linked, neither of which had anywhere to live before.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/lib/session/account-links.ts"],"sourcesContent":["import type { Locale } from '@entifix/core';\n\n/**\n * The catalog keys naming these destinations, in the `shell` namespace.\n *\n * They are keys of `shell` and not of `app` because this list is shell-owned:\n * the module that authors a string owns its catalog entry and resolves it. When\n * they lived under `app:auth.*`, every host app had to resolve a key belonging\n * to a *different* app's sub-tree just to render this menu.\n *\n * Typed as a union rather than `string` so `useT('shell')` still checks them —\n * no `useTranslateKey` escape hatch is needed to render the menu.\n */\nexport type AccountLabelKey =\n 'account.profile' | 'account.security' | 'account.sessions';\n\n/** An account destination: a catalog key plus the path it lives at. */\nexport interface AccountDestination {\n /** Key in the `shell` namespace, resolved by whichever control renders it. */\n readonly labelKey: AccountLabelKey;\n /** Path within the host serving the account surface, with no locale prefix. */\n readonly path: string;\n}\n\n/**\n * Every account screen, in one list.\n *\n * The sidebar section and the account menu are the same three destinations and\n * were drifting as two hand-kept lists; both build from this one now, so adding\n * a screen is a one-line change here.\n *\n * It lives in the shared shell rather than in the auth shell because the menu\n * is rendered by `BackOfficeShell`, which any host mounts — a framework\n * shell cannot reach up into a host's own one.\n */\nexport const ACCOUNT_DESTINATIONS: readonly AccountDestination[] = [\n { labelKey: 'account.profile', path: '/account' },\n // Not `/account/password` any more. r10c holds no password to change, so the\n // screen behind this is a set of links into the provider's own self-service —\n // which is also where a second factor is enrolled and a social account is\n // linked, neither of which had anywhere to live before.\n { labelKey: 'account.security', path: '/account/security' },\n { labelKey: 'account.sessions', path: '/account/sessions' },\n];\n\nexport interface AccountLink {\n readonly labelKey: AccountLabelKey;\n readonly href: string;\n}\n\n/**\n * Account links as locale-prefixed paths on the host that serves them.\n *\n * There is no absolute-URL variant any more. There used to be one, for the apps\n * that linked across to auth-app on another port — the account surface and the\n * back office share an origin now, so every one of these is an ordinary in-app\n * navigation and a cross-origin builder would have no caller.\n */\nexport const accountPaths = (locale: Locale): readonly AccountLink[] =>\n ACCOUNT_DESTINATIONS.map(destination => ({\n labelKey: destination.labelKey,\n href: `/${locale}${destination.path}`,\n }));\n"],"names":["ACCOUNT_DESTINATIONS","labelKey","path","accountPaths","locale","map","destination","href"],"mappings":"AAwBA;;;;;;;;;;CAUC,GACD,OAAO,MAAMA,uBAAsD;IACjE;QAAEC,UAAU;QAAmBC,MAAM;IAAW;IAChD,6EAA6E;IAC7E,8EAA8E;IAC9E,0EAA0E;IAC1E,wDAAwD;IACxD;QAAED,UAAU;QAAoBC,MAAM;IAAoB;IAC1D;QAAED,UAAU;QAAoBC,MAAM;IAAoB;CAC3D,CAAC;AAOF;;;;;;;CAOC,GACD,OAAO,MAAMC,eAAe,CAACC,SAC3BJ,qBAAqBK,GAAG,CAACC,CAAAA,cAAgB,CAAA;YACvCL,UAAUK,YAAYL,QAAQ;YAC9BM,MAAM,CAAC,CAAC,EAAEH,SAASE,YAAYJ,IAAI,EAAE;QACvC,CAAA,GAAI"}
1
+ {"version":3,"sources":["../../../src/lib/session/account-links.ts"],"sourcesContent":["import type { Locale } from '@entifix/core';\n\n/**\n * The catalog keys naming these destinations, in the `shell` namespace.\n *\n * They are keys of `shell` and not of `app` because this list is shell-owned:\n * the module that authors a string owns its catalog entry and resolves it. When\n * they lived under `app:auth.*`, every host app had to resolve a key belonging\n * to a *different* app's sub-tree just to render this menu.\n *\n * Typed as a union rather than `string` so `useT('shell')` still checks them —\n * no `useTranslateKey` escape hatch is needed to render the menu.\n */\nexport type AccountLabelKey =\n 'account.profile' | 'account.security' | 'account.sessions';\n\n/** An account destination: a catalog key plus the path it lives at. */\nexport interface AccountDestination {\n /** Key in the `shell` namespace, resolved by whichever control renders it. */\n readonly labelKey: AccountLabelKey;\n /** Path within the host serving the account surface, with no locale prefix. */\n readonly path: string;\n}\n\n/**\n * Every account screen, in one list.\n *\n * The sidebar section and the account menu are the same three destinations and\n * were drifting as two hand-kept lists; both build from this one now, so adding\n * a screen is a one-line change here.\n *\n * It lives in the shared shell rather than in the auth shell because the menu\n * is rendered by `BackOfficeShell`, which any host mounts — a framework\n * shell cannot reach up into a host's own one.\n */\nexport const ACCOUNT_DESTINATIONS: readonly AccountDestination[] = [\n { labelKey: 'account.profile', path: '/account' },\n // Not `/account/password` any more. The host holds no password to change, so the\n // screen behind this is a set of links into the provider's own self-service —\n // which is also where a second factor is enrolled and a social account is\n // linked, neither of which had anywhere to live before.\n { labelKey: 'account.security', path: '/account/security' },\n { labelKey: 'account.sessions', path: '/account/sessions' },\n];\n\nexport interface AccountLink {\n readonly labelKey: AccountLabelKey;\n readonly href: string;\n}\n\n/**\n * Account links as locale-prefixed paths on the host that serves them.\n *\n * There is no absolute-URL variant any more. There used to be one, for the apps\n * that linked across to auth-app on another port — the account surface and the\n * back office share an origin now, so every one of these is an ordinary in-app\n * navigation and a cross-origin builder would have no caller.\n */\nexport const accountPaths = (locale: Locale): readonly AccountLink[] =>\n ACCOUNT_DESTINATIONS.map(destination => ({\n labelKey: destination.labelKey,\n href: `/${locale}${destination.path}`,\n }));\n"],"names":["ACCOUNT_DESTINATIONS","labelKey","path","accountPaths","locale","map","destination","href"],"mappings":"AAwBA;;;;;;;;;;CAUC,GACD,OAAO,MAAMA,uBAAsD;IACjE;QAAEC,UAAU;QAAmBC,MAAM;IAAW;IAChD,iFAAiF;IACjF,8EAA8E;IAC9E,0EAA0E;IAC1E,wDAAwD;IACxD;QAAED,UAAU;QAAoBC,MAAM;IAAoB;IAC1D;QAAED,UAAU;QAAoBC,MAAM;IAAoB;CAC3D,CAAC;AAOF;;;;;;;CAOC,GACD,OAAO,MAAMC,eAAe,CAACC,SAC3BJ,qBAAqBK,GAAG,CAACC,CAAAA,cAAgB,CAAA;YACvCL,UAAUK,YAAYL,QAAQ;YAC9BM,MAAM,CAAC,CAAC,EAAEH,SAASE,YAAYJ,IAAI,EAAE;QACvC,CAAA,GAAI"}
@@ -15,7 +15,7 @@ export interface SessionKeepaliveProps {
15
15
  * with its schedule, its idle gate, its countdown and a full spec, and was
16
16
  * referenced by nothing — no app, no shell, no layout. So no browser in the
17
17
  * fleet ever posted to `/api/auth/refresh`, and three separately reasonable
18
- * facts combined into a bug (#252): the access token lives 15 minutes, both
18
+ * facts combined into a bug: the access token lives 15 minutes, both
19
19
  * cookies are sized to the seven-day session ceiling so that a cookie does not
20
20
  * sign everyone out every 15 minutes, and the middleware can only check that a
21
21
  * cookie is *present* because the edge cannot verify a signature. The visitor
@@ -32,8 +32,7 @@ export interface SessionKeepaliveProps {
32
32
  * that already exists, and the two would drift the first time either changed.
33
33
  *
34
34
  * A form's unsaved work survives it, because a workspace draft is autosaved to
35
- * IndexedDB rather than held in the page
36
- * ([ADR 0032](../../../../../../docs/adr/0032-what-may-live-in-an-autosaved-draft.md)).
35
+ * IndexedDB rather than held in the page.
37
36
  *
38
37
  * Renders nothing but a warning strip, and only inside the final window before
39
38
  * the ceiling. `role="status"` rather than `role="alert"`: the session ending
@@ -45,7 +44,6 @@ export interface SessionKeepaliveProps {
45
44
  * nothing between that and the neutrals. Borrowing `danger` for "your session
46
45
  * ends in five minutes" would spend the one alarming colour on something that is
47
46
  * not an error; adding a semantic colour is a change to the token contract
48
- * ([ADR 0027](../../../../../../docs/adr/0027-two-scales-a-density-mode-and-the-type-system.md))
49
47
  * and does not belong in a session fix.
50
48
  */
51
49
  export declare function SessionKeepalive({ endpoint, onExpired, }?: SessionKeepaliveProps): import("react").JSX.Element | null;
@@ -1 +1 @@
1
- {"version":3,"file":"session-keepalive.d.ts","sourceRoot":"","sources":["../../../src/lib/session/session-keepalive.tsx"],"names":[],"mappings":"AAOA,MAAM,WAAW,qBAAqB;IACpC,wDAAwD;IACxD,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;OAIG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;CACjC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AACH,wBAAgB,gBAAgB,CAAC,EAC/B,QAAQ,EACR,SAAS,GACV,GAAE,qBAA0B,sCA6B5B"}
1
+ {"version":3,"file":"session-keepalive.d.ts","sourceRoot":"","sources":["../../../src/lib/session/session-keepalive.tsx"],"names":[],"mappings":"AAOA,MAAM,WAAW,qBAAqB;IACpC,wDAAwD;IACxD,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;OAIG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;CACjC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,wBAAgB,gBAAgB,CAAC,EAC/B,QAAQ,EACR,SAAS,GACV,GAAE,qBAA0B,sCA6B5B"}
@@ -10,7 +10,7 @@ import { useSessionRefresh } from './use-session-refresh.js';
10
10
  * with its schedule, its idle gate, its countdown and a full spec, and was
11
11
  * referenced by nothing — no app, no shell, no layout. So no browser in the
12
12
  * fleet ever posted to `/api/auth/refresh`, and three separately reasonable
13
- * facts combined into a bug (#252): the access token lives 15 minutes, both
13
+ * facts combined into a bug: the access token lives 15 minutes, both
14
14
  * cookies are sized to the seven-day session ceiling so that a cookie does not
15
15
  * sign everyone out every 15 minutes, and the middleware can only check that a
16
16
  * cookie is *present* because the edge cannot verify a signature. The visitor
@@ -27,8 +27,7 @@ import { useSessionRefresh } from './use-session-refresh.js';
27
27
  * that already exists, and the two would drift the first time either changed.
28
28
  *
29
29
  * A form's unsaved work survives it, because a workspace draft is autosaved to
30
- * IndexedDB rather than held in the page
31
- * ([ADR 0032](../../../../../../docs/adr/0032-what-may-live-in-an-autosaved-draft.md)).
30
+ * IndexedDB rather than held in the page.
32
31
  *
33
32
  * Renders nothing but a warning strip, and only inside the final window before
34
33
  * the ceiling. `role="status"` rather than `role="alert"`: the session ending
@@ -40,7 +39,6 @@ import { useSessionRefresh } from './use-session-refresh.js';
40
39
  * nothing between that and the neutrals. Borrowing `danger` for "your session
41
40
  * ends in five minutes" would spend the one alarming colour on something that is
42
41
  * not an error; adding a semantic colour is a change to the token contract
43
- * ([ADR 0027](../../../../../../docs/adr/0027-two-scales-a-density-mode-and-the-type-system.md))
44
42
  * and does not belong in a session fix.
45
43
  */ export function SessionKeepalive({ endpoint, onExpired } = {}) {
46
44
  // A reload rather than a route change: middleware is what knows where a
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/lib/session/session-keepalive.tsx"],"sourcesContent":["'use client';\n\nimport { useT } from '@entifix/react-controls';\nimport { useCallback } from 'react';\n\nimport { useSessionRefresh } from './use-session-refresh.js';\n\nexport interface SessionKeepaliveProps {\n /** Where this app mounts the shared refresh handler. */\n readonly endpoint?: string;\n /**\n * What to do when the session is over. Defaults to reloading the page, which\n * is almost always the right answer — see the note on {@link\n * SessionKeepalive}.\n */\n readonly onExpired?: () => void;\n}\n\n/**\n * Mounts {@link useSessionRefresh} and acts on what it reports.\n *\n * ⚠️ **This component is why the hook does anything at all.** The hook shipped\n * with its schedule, its idle gate, its countdown and a full spec, and was\n * referenced by nothing — no app, no shell, no layout. So no browser in the\n * fleet ever posted to `/api/auth/refresh`, and three separately reasonable\n * facts combined into a bug (#252): the access token lives 15 minutes, both\n * cookies are sized to the seven-day session ceiling so that a cookie does not\n * sign everyone out every 15 minutes, and the middleware can only check that a\n * cookie is *present* because the edge cannot verify a signature. The visitor\n * was therefore admitted to pages that rendered fine while every call behind\n * them answered `401`.\n *\n * **Expiry reloads rather than routing anywhere**, and that is the smaller\n * change rather than the lazier one. By the time the hook reports `expired` the\n * refresh route has already cleared both cookies — a dead session clears them\n * instead of leaving the browser presenting a session id that will never\n * refresh again. A reload then meets the middleware with no cookie, and the\n * middleware redirects to sign-in with the right locale and the right\n * `redirect` parameter. Building that URL here would be a second copy of a rule\n * that already exists, and the two would drift the first time either changed.\n *\n * A form's unsaved work survives it, because a workspace draft is autosaved to\n * IndexedDB rather than held in the page\n * ([ADR 0032](../../../../../../docs/adr/0032-what-may-live-in-an-autosaved-draft.md)).\n *\n * Renders nothing but a warning strip, and only inside the final window before\n * the ceiling. `role=\"status\"` rather than `role=\"alert\"`: the session ending\n * soon is a thing to notice, not an error to interrupt for, and an assertive\n * live region would talk over whatever the person is actually doing.\n *\n * Neutral surface tokens rather than a warning colour, because the contract has\n * no warning colour — it carries `danger` and its subtle and content pair, and\n * nothing between that and the neutrals. Borrowing `danger` for \"your session\n * ends in five minutes\" would spend the one alarming colour on something that is\n * not an error; adding a semantic colour is a change to the token contract\n * ([ADR 0027](../../../../../../docs/adr/0027-two-scales-a-density-mode-and-the-type-system.md))\n * and does not belong in a session fix.\n */\nexport function SessionKeepalive({\n endpoint,\n onExpired,\n}: SessionKeepaliveProps = {}) {\n // A reload rather than a route change: middleware is what knows where a\n // signed-out visitor goes, and `router.push` would keep the dead client-side\n // tree around while it got there.\n const expire = useCallback(() => {\n if (onExpired) {\n onExpired();\n return;\n }\n window.location.reload();\n }, [onExpired]);\n\n const { expiringSoon, expired } = useSessionRefresh({\n endpoint,\n onExpired: expire,\n });\n const t = useT('shell');\n\n if (expired || !expiringSoon) return null;\n\n return (\n <div\n role=\"status\"\n data-testid=\"session-expiring\"\n className=\"border-b border-border bg-surface-elevated px-s py-2xs text-step-sm text-content\"\n >\n {t('session.expiringSoon')}\n </div>\n );\n}\n"],"names":["useT","useCallback","useSessionRefresh","SessionKeepalive","endpoint","onExpired","expire","window","location","reload","expiringSoon","expired","t","div","role","data-testid","className"],"mappings":"AAAA;;AAEA,SAASA,IAAI,QAAQ,0BAA0B;AAC/C,SAASC,WAAW,QAAQ,QAAQ;AAEpC,SAASC,iBAAiB,QAAQ,2BAA2B;AAa7D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAuCC,GACD,OAAO,SAASC,iBAAiB,EAC/BC,QAAQ,EACRC,SAAS,EACa,GAAG,CAAC,CAAC;IAC3B,wEAAwE;IACxE,6EAA6E;IAC7E,kCAAkC;IAClC,MAAMC,SAASL,YAAY;QACzB,IAAII,WAAW;YACbA;YACA;QACF;QACAE,OAAOC,QAAQ,CAACC,MAAM;IACxB,GAAG;QAACJ;KAAU;IAEd,MAAM,EAAEK,YAAY,EAAEC,OAAO,EAAE,GAAGT,kBAAkB;QAClDE;QACAC,WAAWC;IACb;IACA,MAAMM,IAAIZ,KAAK;IAEf,IAAIW,WAAW,CAACD,cAAc,OAAO;IAErC,qBACE,KAACG;QACCC,MAAK;QACLC,eAAY;QACZC,WAAU;kBAETJ,EAAE;;AAGT"}
1
+ {"version":3,"sources":["../../../src/lib/session/session-keepalive.tsx"],"sourcesContent":["'use client';\n\nimport { useT } from '@entifix/react-controls';\nimport { useCallback } from 'react';\n\nimport { useSessionRefresh } from './use-session-refresh.js';\n\nexport interface SessionKeepaliveProps {\n /** Where this app mounts the shared refresh handler. */\n readonly endpoint?: string;\n /**\n * What to do when the session is over. Defaults to reloading the page, which\n * is almost always the right answer — see the note on {@link\n * SessionKeepalive}.\n */\n readonly onExpired?: () => void;\n}\n\n/**\n * Mounts {@link useSessionRefresh} and acts on what it reports.\n *\n * ⚠️ **This component is why the hook does anything at all.** The hook shipped\n * with its schedule, its idle gate, its countdown and a full spec, and was\n * referenced by nothing — no app, no shell, no layout. So no browser in the\n * fleet ever posted to `/api/auth/refresh`, and three separately reasonable\n * facts combined into a bug: the access token lives 15 minutes, both\n * cookies are sized to the seven-day session ceiling so that a cookie does not\n * sign everyone out every 15 minutes, and the middleware can only check that a\n * cookie is *present* because the edge cannot verify a signature. The visitor\n * was therefore admitted to pages that rendered fine while every call behind\n * them answered `401`.\n *\n * **Expiry reloads rather than routing anywhere**, and that is the smaller\n * change rather than the lazier one. By the time the hook reports `expired` the\n * refresh route has already cleared both cookies — a dead session clears them\n * instead of leaving the browser presenting a session id that will never\n * refresh again. A reload then meets the middleware with no cookie, and the\n * middleware redirects to sign-in with the right locale and the right\n * `redirect` parameter. Building that URL here would be a second copy of a rule\n * that already exists, and the two would drift the first time either changed.\n *\n * A form's unsaved work survives it, because a workspace draft is autosaved to\n * IndexedDB rather than held in the page.\n *\n * Renders nothing but a warning strip, and only inside the final window before\n * the ceiling. `role=\"status\"` rather than `role=\"alert\"`: the session ending\n * soon is a thing to notice, not an error to interrupt for, and an assertive\n * live region would talk over whatever the person is actually doing.\n *\n * Neutral surface tokens rather than a warning colour, because the contract has\n * no warning colour — it carries `danger` and its subtle and content pair, and\n * nothing between that and the neutrals. Borrowing `danger` for \"your session\n * ends in five minutes\" would spend the one alarming colour on something that is\n * not an error; adding a semantic colour is a change to the token contract\n * and does not belong in a session fix.\n */\nexport function SessionKeepalive({\n endpoint,\n onExpired,\n}: SessionKeepaliveProps = {}) {\n // A reload rather than a route change: middleware is what knows where a\n // signed-out visitor goes, and `router.push` would keep the dead client-side\n // tree around while it got there.\n const expire = useCallback(() => {\n if (onExpired) {\n onExpired();\n return;\n }\n window.location.reload();\n }, [onExpired]);\n\n const { expiringSoon, expired } = useSessionRefresh({\n endpoint,\n onExpired: expire,\n });\n const t = useT('shell');\n\n if (expired || !expiringSoon) return null;\n\n return (\n <div\n role=\"status\"\n data-testid=\"session-expiring\"\n className=\"border-b border-border bg-surface-elevated px-s py-2xs text-step-sm text-content\"\n >\n {t('session.expiringSoon')}\n </div>\n );\n}\n"],"names":["useT","useCallback","useSessionRefresh","SessionKeepalive","endpoint","onExpired","expire","window","location","reload","expiringSoon","expired","t","div","role","data-testid","className"],"mappings":"AAAA;;AAEA,SAASA,IAAI,QAAQ,0BAA0B;AAC/C,SAASC,WAAW,QAAQ,QAAQ;AAEpC,SAASC,iBAAiB,QAAQ,2BAA2B;AAa7D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAqCC,GACD,OAAO,SAASC,iBAAiB,EAC/BC,QAAQ,EACRC,SAAS,EACa,GAAG,CAAC,CAAC;IAC3B,wEAAwE;IACxE,6EAA6E;IAC7E,kCAAkC;IAClC,MAAMC,SAASL,YAAY;QACzB,IAAII,WAAW;YACbA;YACA;QACF;QACAE,OAAOC,QAAQ,CAACC,MAAM;IACxB,GAAG;QAACJ;KAAU;IAEd,MAAM,EAAEK,YAAY,EAAEC,OAAO,EAAE,GAAGT,kBAAkB;QAClDE;QACAC,WAAWC;IACb;IACA,MAAMM,IAAIZ,KAAK;IAEf,IAAIW,WAAW,CAACD,cAAc,OAAO;IAErC,qBACE,KAACG;QACCC,MAAK;QACLC,eAAY;QACZC,WAAU;kBAETJ,EAAE;;AAGT"}
@@ -12,7 +12,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
12
12
  * ⚠️ **Defaults, not policy.** These four used to be imported from the
13
13
  * application's own session package, which made a framework hook carry one
14
14
  * product's security decisions — and meant a second application could not use
15
- * the hook at all without adopting r10c's token lifetime. A host that has a
15
+ * the hook at all without adopting the first one's token lifetime. A host that has a
16
16
  * session policy passes it in; a host that has not yet thought about it gets
17
17
  * numbers that are defensible rather than absent.
18
18
  *
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/lib/session/use-session-refresh.ts"],"sourcesContent":["'use client';\n\nimport { useCallback, useEffect, useRef, useState } from 'react';\n\n/** Browser events that count as a person being present. */\nconst ACTIVITY_EVENTS = [\n 'pointerdown',\n 'keydown',\n 'scroll',\n 'visibilitychange',\n] as const;\n\n/**\n * Session timing, as this shell assumes it when a host says nothing.\n *\n * ⚠️ **Defaults, not policy.** These four used to be imported from the\n * application's own session package, which made a framework hook carry one\n * product's security decisions — and meant a second application could not use\n * the hook at all without adopting r10c's token lifetime. A host that has a\n * session policy passes it in; a host that has not yet thought about it gets\n * numbers that are defensible rather than absent.\n *\n * The server is still the authority. These only decide when the browser *asks*;\n * the refresh endpoint decides whether the session actually slides.\n */\nconst DEFAULT_TOKEN_TTL_SECONDS = 60 * 15;\nconst DEFAULT_IDLE_STOP_SECONDS = 60 * 15;\nconst DEFAULT_EXPIRY_WARNING_SECONDS = 60 * 5;\nconst DEFAULT_REFRESH_LEAD_RATIO = 0.8;\n\nexport interface UseSessionRefreshOptions {\n /** Where this app mounts the shared refresh handler. */\n readonly endpoint?: string;\n /** Access-token lifetime in seconds; the schedule derives from it. */\n readonly tokenTtlSeconds?: number;\n /**\n * How long without interaction before the timer stops asking.\n *\n * This is what makes \"idle timeout\" measure whether a person is there rather\n * than whether a tab is open, so a host raising it is choosing to keep\n * forgotten tabs alive.\n */\n readonly idleStopSeconds?: number;\n /** How long before the absolute ceiling `expiringSoon` turns true. */\n readonly expiryWarningSeconds?: number;\n /** Fraction of the token's life at which to refresh. */\n readonly refreshLeadRatio?: number;\n /** Called when the session is gone and the user has to sign in again. */\n readonly onExpired?: () => void;\n}\n\nexport interface SessionRefreshState {\n /** Seconds left before the session's absolute ceiling, once known. */\n readonly sessionExpiresIn: number | undefined;\n /** True inside the final window before the ceiling — prompt the user. */\n readonly expiringSoon: boolean;\n /** True once a refresh has failed and the session is over. */\n readonly expired: boolean;\n}\n\n/**\n * Keeps the access-token cookie fresh while someone is actually using the app.\n *\n * The refresh runs on a timer at 80% of the token's life, but **only while the\n * user has interacted recently**. That condition is the whole point: the server\n * slides the session on refresh, so a timer that fired unconditionally would\n * keep a forgotten tab's session alive forever and \"idle timeout\" would measure\n * whether a tab is open rather than whether a person is there. Going quiet lets\n * the token lapse and the session age out on schedule.\n *\n * The token itself is never visible here — it lives in an httpOnly cookie, and\n * the endpoint below rewrites it server-side.\n */\nexport function useSessionRefresh(\n options: UseSessionRefreshOptions = {},\n): SessionRefreshState {\n const {\n endpoint = '/api/auth/refresh',\n tokenTtlSeconds = DEFAULT_TOKEN_TTL_SECONDS,\n idleStopSeconds = DEFAULT_IDLE_STOP_SECONDS,\n expiryWarningSeconds = DEFAULT_EXPIRY_WARNING_SECONDS,\n refreshLeadRatio = DEFAULT_REFRESH_LEAD_RATIO,\n onExpired,\n } = options;\n\n const [sessionExpiresIn, setSessionExpiresIn] = useState<number | undefined>(\n undefined,\n );\n const [expired, setExpired] = useState(false);\n // Seeded on mount rather than at construction: reading the clock during\n // render is impure, and the value is only ever consumed from an effect.\n const lastActivity = useRef(0);\n const onExpiredRef = useRef(onExpired);\n /**\n * Mirrors `expired` synchronously. The state update only tears the interval\n * down on the next render, so without this a burst of ticks fires — and\n * `onExpired` runs — several times over for one dead session.\n */\n const expiredRef = useRef(false);\n\n useEffect(() => {\n onExpiredRef.current = onExpired;\n }, [onExpired]);\n\n const markActive = useCallback(() => {\n lastActivity.current = Date.now();\n }, []);\n\n useEffect(() => {\n markActive();\n for (const event of ACTIVITY_EVENTS) {\n window.addEventListener(event, markActive, { passive: true });\n }\n return () => {\n for (const event of ACTIVITY_EVENTS) {\n window.removeEventListener(event, markActive);\n }\n };\n }, [markActive]);\n\n useEffect(() => {\n if (expired) return undefined;\n\n const periodMs =\n Math.max(1, Math.floor(tokenTtlSeconds * refreshLeadRatio)) * 1000;\n let cancelled = false;\n\n const tick = async () => {\n if (expiredRef.current) return;\n\n const idleFor = (Date.now() - lastActivity.current) / 1000;\n // Nobody is here. Skipping the refresh is what lets the session lapse.\n if (idleFor > idleStopSeconds) return;\n\n try {\n const response = await fetch(endpoint, {\n method: 'POST',\n cache: 'no-store',\n });\n if (cancelled) return;\n\n if (response.status === 401) {\n expiredRef.current = true;\n setExpired(true);\n onExpiredRef.current?.();\n return;\n }\n if (!response.ok) return; // transient (503) — try again next tick\n\n const body = (await response.json()) as { sessionExpiresIn?: number };\n if (!cancelled && typeof body.sessionExpiresIn === 'number') {\n setSessionExpiresIn(body.sessionExpiresIn);\n }\n } catch {\n // Offline or a dropped request: keep the schedule and retry.\n }\n };\n\n const timer = setInterval(tick, periodMs);\n return () => {\n cancelled = true;\n clearInterval(timer);\n };\n }, [endpoint, tokenTtlSeconds, expired]);\n\n return {\n sessionExpiresIn,\n expiringSoon:\n sessionExpiresIn !== undefined &&\n sessionExpiresIn <= expiryWarningSeconds,\n expired,\n };\n}\n"],"names":["useCallback","useEffect","useRef","useState","ACTIVITY_EVENTS","DEFAULT_TOKEN_TTL_SECONDS","DEFAULT_IDLE_STOP_SECONDS","DEFAULT_EXPIRY_WARNING_SECONDS","DEFAULT_REFRESH_LEAD_RATIO","useSessionRefresh","options","endpoint","tokenTtlSeconds","idleStopSeconds","expiryWarningSeconds","refreshLeadRatio","onExpired","sessionExpiresIn","setSessionExpiresIn","undefined","expired","setExpired","lastActivity","onExpiredRef","expiredRef","current","markActive","Date","now","event","window","addEventListener","passive","removeEventListener","periodMs","Math","max","floor","cancelled","tick","idleFor","response","fetch","method","cache","status","ok","body","json","timer","setInterval","clearInterval","expiringSoon"],"mappings":"AAAA;AAEA,SAASA,WAAW,EAAEC,SAAS,EAAEC,MAAM,EAAEC,QAAQ,QAAQ,QAAQ;AAEjE,yDAAyD,GACzD,MAAMC,kBAAkB;IACtB;IACA;IACA;IACA;CACD;AAED;;;;;;;;;;;;CAYC,GACD,MAAMC,4BAA4B,KAAK;AACvC,MAAMC,4BAA4B,KAAK;AACvC,MAAMC,iCAAiC,KAAK;AAC5C,MAAMC,6BAA6B;AAgCnC;;;;;;;;;;;;CAYC,GACD,OAAO,SAASC,kBACdC,UAAoC,CAAC,CAAC;IAEtC,MAAM,EACJC,WAAW,mBAAmB,EAC9BC,kBAAkBP,yBAAyB,EAC3CQ,kBAAkBP,yBAAyB,EAC3CQ,uBAAuBP,8BAA8B,EACrDQ,mBAAmBP,0BAA0B,EAC7CQ,SAAS,EACV,GAAGN;IAEJ,MAAM,CAACO,kBAAkBC,oBAAoB,GAAGf,SAC9CgB;IAEF,MAAM,CAACC,SAASC,WAAW,GAAGlB,SAAS;IACvC,wEAAwE;IACxE,wEAAwE;IACxE,MAAMmB,eAAepB,OAAO;IAC5B,MAAMqB,eAAerB,OAAOc;IAC5B;;;;GAIC,GACD,MAAMQ,aAAatB,OAAO;IAE1BD,UAAU;QACRsB,aAAaE,OAAO,GAAGT;IACzB,GAAG;QAACA;KAAU;IAEd,MAAMU,aAAa1B,YAAY;QAC7BsB,aAAaG,OAAO,GAAGE,KAAKC,GAAG;IACjC,GAAG,EAAE;IAEL3B,UAAU;QACRyB;QACA,KAAK,MAAMG,SAASzB,gBAAiB;YACnC0B,OAAOC,gBAAgB,CAACF,OAAOH,YAAY;gBAAEM,SAAS;YAAK;QAC7D;QACA,OAAO;YACL,KAAK,MAAMH,SAASzB,gBAAiB;gBACnC0B,OAAOG,mBAAmB,CAACJ,OAAOH;YACpC;QACF;IACF,GAAG;QAACA;KAAW;IAEfzB,UAAU;QACR,IAAImB,SAAS,OAAOD;QAEpB,MAAMe,WACJC,KAAKC,GAAG,CAAC,GAAGD,KAAKE,KAAK,CAACzB,kBAAkBG,qBAAqB;QAChE,IAAIuB,YAAY;QAEhB,MAAMC,OAAO;YACX,IAAIf,WAAWC,OAAO,EAAE;YAExB,MAAMe,UAAU,AAACb,CAAAA,KAAKC,GAAG,KAAKN,aAAaG,OAAO,AAAD,IAAK;YACtD,uEAAuE;YACvE,IAAIe,UAAU3B,iBAAiB;YAE/B,IAAI;gBACF,MAAM4B,WAAW,MAAMC,MAAM/B,UAAU;oBACrCgC,QAAQ;oBACRC,OAAO;gBACT;gBACA,IAAIN,WAAW;gBAEf,IAAIG,SAASI,MAAM,KAAK,KAAK;oBAC3BrB,WAAWC,OAAO,GAAG;oBACrBJ,WAAW;oBACXE,aAAaE,OAAO,oBAApBF,aAAaE,OAAO,MAApBF;oBACA;gBACF;gBACA,IAAI,CAACkB,SAASK,EAAE,EAAE,QAAQ,wCAAwC;gBAElE,MAAMC,OAAQ,MAAMN,SAASO,IAAI;gBACjC,IAAI,CAACV,aAAa,OAAOS,KAAK9B,gBAAgB,KAAK,UAAU;oBAC3DC,oBAAoB6B,KAAK9B,gBAAgB;gBAC3C;YACF,EAAE,eAAM;YACN,6DAA6D;YAC/D;QACF;QAEA,MAAMgC,QAAQC,YAAYX,MAAML;QAChC,OAAO;YACLI,YAAY;YACZa,cAAcF;QAChB;IACF,GAAG;QAACtC;QAAUC;QAAiBQ;KAAQ;IAEvC,OAAO;QACLH;QACAmC,cACEnC,qBAAqBE,aACrBF,oBAAoBH;QACtBM;IACF;AACF"}
1
+ {"version":3,"sources":["../../../src/lib/session/use-session-refresh.ts"],"sourcesContent":["'use client';\n\nimport { useCallback, useEffect, useRef, useState } from 'react';\n\n/** Browser events that count as a person being present. */\nconst ACTIVITY_EVENTS = [\n 'pointerdown',\n 'keydown',\n 'scroll',\n 'visibilitychange',\n] as const;\n\n/**\n * Session timing, as this shell assumes it when a host says nothing.\n *\n * ⚠️ **Defaults, not policy.** These four used to be imported from the\n * application's own session package, which made a framework hook carry one\n * product's security decisions — and meant a second application could not use\n * the hook at all without adopting the first one's token lifetime. A host that has a\n * session policy passes it in; a host that has not yet thought about it gets\n * numbers that are defensible rather than absent.\n *\n * The server is still the authority. These only decide when the browser *asks*;\n * the refresh endpoint decides whether the session actually slides.\n */\nconst DEFAULT_TOKEN_TTL_SECONDS = 60 * 15;\nconst DEFAULT_IDLE_STOP_SECONDS = 60 * 15;\nconst DEFAULT_EXPIRY_WARNING_SECONDS = 60 * 5;\nconst DEFAULT_REFRESH_LEAD_RATIO = 0.8;\n\nexport interface UseSessionRefreshOptions {\n /** Where this app mounts the shared refresh handler. */\n readonly endpoint?: string;\n /** Access-token lifetime in seconds; the schedule derives from it. */\n readonly tokenTtlSeconds?: number;\n /**\n * How long without interaction before the timer stops asking.\n *\n * This is what makes \"idle timeout\" measure whether a person is there rather\n * than whether a tab is open, so a host raising it is choosing to keep\n * forgotten tabs alive.\n */\n readonly idleStopSeconds?: number;\n /** How long before the absolute ceiling `expiringSoon` turns true. */\n readonly expiryWarningSeconds?: number;\n /** Fraction of the token's life at which to refresh. */\n readonly refreshLeadRatio?: number;\n /** Called when the session is gone and the user has to sign in again. */\n readonly onExpired?: () => void;\n}\n\nexport interface SessionRefreshState {\n /** Seconds left before the session's absolute ceiling, once known. */\n readonly sessionExpiresIn: number | undefined;\n /** True inside the final window before the ceiling — prompt the user. */\n readonly expiringSoon: boolean;\n /** True once a refresh has failed and the session is over. */\n readonly expired: boolean;\n}\n\n/**\n * Keeps the access-token cookie fresh while someone is actually using the app.\n *\n * The refresh runs on a timer at 80% of the token's life, but **only while the\n * user has interacted recently**. That condition is the whole point: the server\n * slides the session on refresh, so a timer that fired unconditionally would\n * keep a forgotten tab's session alive forever and \"idle timeout\" would measure\n * whether a tab is open rather than whether a person is there. Going quiet lets\n * the token lapse and the session age out on schedule.\n *\n * The token itself is never visible here — it lives in an httpOnly cookie, and\n * the endpoint below rewrites it server-side.\n */\nexport function useSessionRefresh(\n options: UseSessionRefreshOptions = {},\n): SessionRefreshState {\n const {\n endpoint = '/api/auth/refresh',\n tokenTtlSeconds = DEFAULT_TOKEN_TTL_SECONDS,\n idleStopSeconds = DEFAULT_IDLE_STOP_SECONDS,\n expiryWarningSeconds = DEFAULT_EXPIRY_WARNING_SECONDS,\n refreshLeadRatio = DEFAULT_REFRESH_LEAD_RATIO,\n onExpired,\n } = options;\n\n const [sessionExpiresIn, setSessionExpiresIn] = useState<number | undefined>(\n undefined,\n );\n const [expired, setExpired] = useState(false);\n // Seeded on mount rather than at construction: reading the clock during\n // render is impure, and the value is only ever consumed from an effect.\n const lastActivity = useRef(0);\n const onExpiredRef = useRef(onExpired);\n /**\n * Mirrors `expired` synchronously. The state update only tears the interval\n * down on the next render, so without this a burst of ticks fires — and\n * `onExpired` runs — several times over for one dead session.\n */\n const expiredRef = useRef(false);\n\n useEffect(() => {\n onExpiredRef.current = onExpired;\n }, [onExpired]);\n\n const markActive = useCallback(() => {\n lastActivity.current = Date.now();\n }, []);\n\n useEffect(() => {\n markActive();\n for (const event of ACTIVITY_EVENTS) {\n window.addEventListener(event, markActive, { passive: true });\n }\n return () => {\n for (const event of ACTIVITY_EVENTS) {\n window.removeEventListener(event, markActive);\n }\n };\n }, [markActive]);\n\n useEffect(() => {\n if (expired) return undefined;\n\n const periodMs =\n Math.max(1, Math.floor(tokenTtlSeconds * refreshLeadRatio)) * 1000;\n let cancelled = false;\n\n const tick = async () => {\n if (expiredRef.current) return;\n\n const idleFor = (Date.now() - lastActivity.current) / 1000;\n // Nobody is here. Skipping the refresh is what lets the session lapse.\n if (idleFor > idleStopSeconds) return;\n\n try {\n const response = await fetch(endpoint, {\n method: 'POST',\n cache: 'no-store',\n });\n if (cancelled) return;\n\n if (response.status === 401) {\n expiredRef.current = true;\n setExpired(true);\n onExpiredRef.current?.();\n return;\n }\n if (!response.ok) return; // transient (503) — try again next tick\n\n const body = (await response.json()) as { sessionExpiresIn?: number };\n if (!cancelled && typeof body.sessionExpiresIn === 'number') {\n setSessionExpiresIn(body.sessionExpiresIn);\n }\n } catch {\n // Offline or a dropped request: keep the schedule and retry.\n }\n };\n\n const timer = setInterval(tick, periodMs);\n return () => {\n cancelled = true;\n clearInterval(timer);\n };\n }, [endpoint, tokenTtlSeconds, expired]);\n\n return {\n sessionExpiresIn,\n expiringSoon:\n sessionExpiresIn !== undefined &&\n sessionExpiresIn <= expiryWarningSeconds,\n expired,\n };\n}\n"],"names":["useCallback","useEffect","useRef","useState","ACTIVITY_EVENTS","DEFAULT_TOKEN_TTL_SECONDS","DEFAULT_IDLE_STOP_SECONDS","DEFAULT_EXPIRY_WARNING_SECONDS","DEFAULT_REFRESH_LEAD_RATIO","useSessionRefresh","options","endpoint","tokenTtlSeconds","idleStopSeconds","expiryWarningSeconds","refreshLeadRatio","onExpired","sessionExpiresIn","setSessionExpiresIn","undefined","expired","setExpired","lastActivity","onExpiredRef","expiredRef","current","markActive","Date","now","event","window","addEventListener","passive","removeEventListener","periodMs","Math","max","floor","cancelled","tick","idleFor","response","fetch","method","cache","status","ok","body","json","timer","setInterval","clearInterval","expiringSoon"],"mappings":"AAAA;AAEA,SAASA,WAAW,EAAEC,SAAS,EAAEC,MAAM,EAAEC,QAAQ,QAAQ,QAAQ;AAEjE,yDAAyD,GACzD,MAAMC,kBAAkB;IACtB;IACA;IACA;IACA;CACD;AAED;;;;;;;;;;;;CAYC,GACD,MAAMC,4BAA4B,KAAK;AACvC,MAAMC,4BAA4B,KAAK;AACvC,MAAMC,iCAAiC,KAAK;AAC5C,MAAMC,6BAA6B;AAgCnC;;;;;;;;;;;;CAYC,GACD,OAAO,SAASC,kBACdC,UAAoC,CAAC,CAAC;IAEtC,MAAM,EACJC,WAAW,mBAAmB,EAC9BC,kBAAkBP,yBAAyB,EAC3CQ,kBAAkBP,yBAAyB,EAC3CQ,uBAAuBP,8BAA8B,EACrDQ,mBAAmBP,0BAA0B,EAC7CQ,SAAS,EACV,GAAGN;IAEJ,MAAM,CAACO,kBAAkBC,oBAAoB,GAAGf,SAC9CgB;IAEF,MAAM,CAACC,SAASC,WAAW,GAAGlB,SAAS;IACvC,wEAAwE;IACxE,wEAAwE;IACxE,MAAMmB,eAAepB,OAAO;IAC5B,MAAMqB,eAAerB,OAAOc;IAC5B;;;;GAIC,GACD,MAAMQ,aAAatB,OAAO;IAE1BD,UAAU;QACRsB,aAAaE,OAAO,GAAGT;IACzB,GAAG;QAACA;KAAU;IAEd,MAAMU,aAAa1B,YAAY;QAC7BsB,aAAaG,OAAO,GAAGE,KAAKC,GAAG;IACjC,GAAG,EAAE;IAEL3B,UAAU;QACRyB;QACA,KAAK,MAAMG,SAASzB,gBAAiB;YACnC0B,OAAOC,gBAAgB,CAACF,OAAOH,YAAY;gBAAEM,SAAS;YAAK;QAC7D;QACA,OAAO;YACL,KAAK,MAAMH,SAASzB,gBAAiB;gBACnC0B,OAAOG,mBAAmB,CAACJ,OAAOH;YACpC;QACF;IACF,GAAG;QAACA;KAAW;IAEfzB,UAAU;QACR,IAAImB,SAAS,OAAOD;QAEpB,MAAMe,WACJC,KAAKC,GAAG,CAAC,GAAGD,KAAKE,KAAK,CAACzB,kBAAkBG,qBAAqB;QAChE,IAAIuB,YAAY;QAEhB,MAAMC,OAAO;YACX,IAAIf,WAAWC,OAAO,EAAE;YAExB,MAAMe,UAAU,AAACb,CAAAA,KAAKC,GAAG,KAAKN,aAAaG,OAAO,AAAD,IAAK;YACtD,uEAAuE;YACvE,IAAIe,UAAU3B,iBAAiB;YAE/B,IAAI;gBACF,MAAM4B,WAAW,MAAMC,MAAM/B,UAAU;oBACrCgC,QAAQ;oBACRC,OAAO;gBACT;gBACA,IAAIN,WAAW;gBAEf,IAAIG,SAASI,MAAM,KAAK,KAAK;oBAC3BrB,WAAWC,OAAO,GAAG;oBACrBJ,WAAW;oBACXE,aAAaE,OAAO,oBAApBF,aAAaE,OAAO,MAApBF;oBACA;gBACF;gBACA,IAAI,CAACkB,SAASK,EAAE,EAAE,QAAQ,wCAAwC;gBAElE,MAAMC,OAAQ,MAAMN,SAASO,IAAI;gBACjC,IAAI,CAACV,aAAa,OAAOS,KAAK9B,gBAAgB,KAAK,UAAU;oBAC3DC,oBAAoB6B,KAAK9B,gBAAgB;gBAC3C;YACF,EAAE,eAAM;YACN,6DAA6D;YAC/D;QACF;QAEA,MAAMgC,QAAQC,YAAYX,MAAML;QAChC,OAAO;YACLI,YAAY;YACZa,cAAcF;QAChB;IACF,GAAG;QAACtC;QAAUC;QAAiBQ;KAAQ;IAEvC,OAAO;QACLH;QACAmC,cACEnC,qBAAqBE,aACrBF,oBAAoBH;QACtBM;IACF;AACF"}
@@ -12,8 +12,7 @@ export interface WizardTabScreen {
12
12
  * opens it at a step.
13
13
  *
14
14
  * The third segment is a **step** here and a record under `master:`, which is
15
- * the one thing [ADR 0045](../../../../../../../docs/adr/0045-the-wizard-a-step-graph-and-a-submit-that-hands-off.md)
16
- * changed about the address grammar — `screenAddress` and `parseScreenPayload`
15
+ * the one thing the wizard changed about the address grammar — `screenAddress` and `parseScreenPayload`
17
16
  * already produced and accepted the shape.
18
17
  *
19
18
  * **The caption is the wizard's name and not the step's**, deliberately: a tab
@@ -1 +1 @@
1
- {"version":3,"file":"wizard-tab-kind.d.ts","sourceRoot":"","sources":["../../../src/lib/wizard/wizard-tab-kind.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAEvC,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,0BAA0B,CAAC;AAExD,iEAAiE;AACjE,MAAM,WAAW,eAAe;IAC9B,2EAA2E;IAC3E,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,kFAAkF;IAClF,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,SAAS,CAAC;CAC7C;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,aAAa,CAC3B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,GACvC,OAAO,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,EAAE,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAavC"}
1
+ {"version":3,"file":"wizard-tab-kind.d.ts","sourceRoot":"","sources":["../../../src/lib/wizard/wizard-tab-kind.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAEvC,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,0BAA0B,CAAC;AAExD,iEAAiE;AACjE,MAAM,WAAW,eAAe;IAC9B,2EAA2E;IAC3E,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,kFAAkF;IAClF,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,SAAS,CAAC;CAC7C;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,aAAa,CAC3B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,GACvC,OAAO,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,EAAE,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAavC"}
@@ -5,8 +5,7 @@ import { parseScreenPayload } from '@entifix/authz';
5
5
  * opens it at a step.
6
6
  *
7
7
  * The third segment is a **step** here and a record under `master:`, which is
8
- * the one thing [ADR 0045](../../../../../../../docs/adr/0045-the-wizard-a-step-graph-and-a-submit-that-hands-off.md)
9
- * changed about the address grammar — `screenAddress` and `parseScreenPayload`
8
+ * the one thing the wizard changed about the address grammar — `screenAddress` and `parseScreenPayload`
10
9
  * already produced and accepted the shape.
11
10
  *
12
11
  * **The caption is the wizard's name and not the step's**, deliberately: a tab
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/lib/wizard/wizard-tab-kind.tsx"],"sourcesContent":["'use client';\n\nimport { parseScreenPayload } from '@entifix/authz';\nimport type { ReactNode } from 'react';\n\nimport type { TabKind } from '../workspace/tab-kind.js';\n\n/** One wizard a workspace may open, as the registry needs it. */\nexport interface WizardTabScreen {\n /** The tab caption's catalog key — the wizard's name, never the step's. */\n readonly titleKey: string;\n /** `step` is the third address segment, absent while the flow is at its first. */\n render(step: string | undefined): ReactNode;\n}\n\n/**\n * The `wizard:` tab kind: `wizard:<key>` opens a flow, `wizard:<key>:<step>`\n * opens it at a step.\n *\n * The third segment is a **step** here and a record under `master:`, which is\n * the one thing [ADR 0045](../../../../../../../docs/adr/0045-the-wizard-a-step-graph-and-a-submit-that-hands-off.md)\n * changed about the address grammar — `screenAddress` and `parseScreenPayload`\n * already produced and accepted the shape.\n *\n * **The caption is the wizard's name and not the step's**, deliberately: a tab\n * whose title changed on every Siguiente would reflow the strip mid-flow and\n * make an open wizard hard to find again by the name it was opened under.\n *\n * A factory rather than a constant, for the reason `masterKind` is built in the\n * host: which screens a workspace offers is the host's decision, and the shells\n * that own the wizards may not import each other.\n */\nexport function wizardTabKind(\n screens: Record<string, WizardTabScreen>,\n): TabKind<{ key: string; id?: string }> {\n return {\n kind: 'wizard',\n match: payload => {\n const parsed = parseScreenPayload(payload);\n if (parsed === null) return null;\n return parsed.key in screens ? parsed : null;\n },\n toParam: addr =>\n addr.id === undefined ? addr.key : `${addr.key}:${addr.id}`,\n title: (addr, translate) => translate(screens[addr.key].titleKey),\n render: addr => screens[addr.key].render(addr.id),\n };\n}\n"],"names":["parseScreenPayload","wizardTabKind","screens","kind","match","payload","parsed","key","toParam","addr","id","undefined","title","translate","titleKey","render"],"mappings":"AAAA;AAEA,SAASA,kBAAkB,QAAQ,iBAAiB;AAapD;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,SAASC,cACdC,OAAwC;IAExC,OAAO;QACLC,MAAM;QACNC,OAAOC,CAAAA;YACL,MAAMC,SAASN,mBAAmBK;YAClC,IAAIC,WAAW,MAAM,OAAO;YAC5B,OAAOA,OAAOC,GAAG,IAAIL,UAAUI,SAAS;QAC1C;QACAE,SAASC,CAAAA,OACPA,KAAKC,EAAE,KAAKC,YAAYF,KAAKF,GAAG,GAAG,GAAGE,KAAKF,GAAG,CAAC,CAAC,EAAEE,KAAKC,EAAE,EAAE;QAC7DE,OAAO,CAACH,MAAMI,YAAcA,UAAUX,OAAO,CAACO,KAAKF,GAAG,CAAC,CAACO,QAAQ;QAChEC,QAAQN,CAAAA,OAAQP,OAAO,CAACO,KAAKF,GAAG,CAAC,CAACQ,MAAM,CAACN,KAAKC,EAAE;IAClD;AACF"}
1
+ {"version":3,"sources":["../../../src/lib/wizard/wizard-tab-kind.tsx"],"sourcesContent":["'use client';\n\nimport { parseScreenPayload } from '@entifix/authz';\nimport type { ReactNode } from 'react';\n\nimport type { TabKind } from '../workspace/tab-kind.js';\n\n/** One wizard a workspace may open, as the registry needs it. */\nexport interface WizardTabScreen {\n /** The tab caption's catalog key — the wizard's name, never the step's. */\n readonly titleKey: string;\n /** `step` is the third address segment, absent while the flow is at its first. */\n render(step: string | undefined): ReactNode;\n}\n\n/**\n * The `wizard:` tab kind: `wizard:<key>` opens a flow, `wizard:<key>:<step>`\n * opens it at a step.\n *\n * The third segment is a **step** here and a record under `master:`, which is\n * the one thing the wizard changed about the address grammar — `screenAddress` and `parseScreenPayload`\n * already produced and accepted the shape.\n *\n * **The caption is the wizard's name and not the step's**, deliberately: a tab\n * whose title changed on every Siguiente would reflow the strip mid-flow and\n * make an open wizard hard to find again by the name it was opened under.\n *\n * A factory rather than a constant, for the reason `masterKind` is built in the\n * host: which screens a workspace offers is the host's decision, and the shells\n * that own the wizards may not import each other.\n */\nexport function wizardTabKind(\n screens: Record<string, WizardTabScreen>,\n): TabKind<{ key: string; id?: string }> {\n return {\n kind: 'wizard',\n match: payload => {\n const parsed = parseScreenPayload(payload);\n if (parsed === null) return null;\n return parsed.key in screens ? parsed : null;\n },\n toParam: addr =>\n addr.id === undefined ? addr.key : `${addr.key}:${addr.id}`,\n title: (addr, translate) => translate(screens[addr.key].titleKey),\n render: addr => screens[addr.key].render(addr.id),\n };\n}\n"],"names":["parseScreenPayload","wizardTabKind","screens","kind","match","payload","parsed","key","toParam","addr","id","undefined","title","translate","titleKey","render"],"mappings":"AAAA;AAEA,SAASA,kBAAkB,QAAQ,iBAAiB;AAapD;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASC,cACdC,OAAwC;IAExC,OAAO;QACLC,MAAM;QACNC,OAAOC,CAAAA;YACL,MAAMC,SAASN,mBAAmBK;YAClC,IAAIC,WAAW,MAAM,OAAO;YAC5B,OAAOA,OAAOC,GAAG,IAAIL,UAAUI,SAAS;QAC1C;QACAE,SAASC,CAAAA,OACPA,KAAKC,EAAE,KAAKC,YAAYF,KAAKF,GAAG,GAAG,GAAGE,KAAKF,GAAG,CAAC,CAAC,EAAEE,KAAKC,EAAE,EAAE;QAC7DE,OAAO,CAACH,MAAMI,YAAcA,UAAUX,OAAO,CAACO,KAAKF,GAAG,CAAC,CAACO,QAAQ;QAChEC,QAAQN,CAAAA,OAAQP,OAAO,CAACO,KAAKF,GAAG,CAAC,CAACQ,MAAM,CAACN,KAAKC,EAAE;IAClD;AACF"}
@@ -1 +1 @@
1
- {"version":3,"file":"drafts-state.d.ts","sourceRoot":"","sources":["../../../src/lib/workspace/drafts-state.ts"],"names":[],"mappings":"AAEA,OAAO,EAAe,KAAK,SAAS,EAAE,MAAM,eAAe,CAAC;AAQ5D;;;;;;;;;;;;;;GAcG;AACH,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;IAClC,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,GAAG,IAAI,CAAC;IAClD,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CACnC;AAED,wBAAgB,eAAe,CAC7B,KAAK,EAAE,WAAW,GACjB,IAAI,CAAC,WAAW,EAAE,QAAQ,CAAC,CAE7B;AAID;;;;;;;;;GASG;AAGH,eAAO,MAAM,cAAc,IAAI,CAAC;AAIhC;;;;;;;;;GASG;AACH,wBAAgB,aAAa,IAAI,IAAI,CAAC,WAAW,EAAE,QAAQ,CAAC,CAE3D;AAED;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CACzB,cAAc,EAAE,OAAO,EACvB,YAAY,EAAE,WAAW,GACxB,WAAW,CASb;AAED,eAAO,MAAM,cAAc;;;;;;;;;;;;EA6B1B,CAAC;AAEF;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,QAAQ,CAAC,MAAM,SAAS,SAAS,EAAE,OAAO,EAAE,MAAM;;sBAYtD,MAAM;;EAMjB"}
1
+ {"version":3,"file":"drafts-state.d.ts","sourceRoot":"","sources":["../../../src/lib/workspace/drafts-state.ts"],"names":[],"mappings":"AAEA,OAAO,EAAe,KAAK,SAAS,EAAE,MAAM,eAAe,CAAC;AAQ5D;;;;;;;;;;;;;;GAcG;AACH,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;IAClC,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,GAAG,IAAI,CAAC;IAClD,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CACnC;AAED,wBAAgB,eAAe,CAC7B,KAAK,EAAE,WAAW,GACjB,IAAI,CAAC,WAAW,EAAE,QAAQ,CAAC,CAE7B;AAID;;;;;;;;;GASG;AAIH,eAAO,MAAM,cAAc,IAAI,CAAC;AAIhC;;;;;;;;;GASG;AACH,wBAAgB,aAAa,IAAI,IAAI,CAAC,WAAW,EAAE,QAAQ,CAAC,CAE3D;AAED;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CACzB,cAAc,EAAE,OAAO,EACvB,YAAY,EAAE,WAAW,GACxB,WAAW,CASb;AAED,eAAO,MAAM,cAAc;;;;;;;;;;;;EA6B1B,CAAC;AAEF;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,QAAQ,CAAC,MAAM,SAAS,SAAS,EAAE,OAAO,EAAE,MAAM;;sBAYtD,MAAM;;EAMjB"}
@@ -23,8 +23,9 @@ const DRAFTS_STORE = 'stores';
23
23
  * which members it should have. That half is `restoreEntityDraft`, which layers
24
24
  * a restored draft over a freshly seeded one so a drifted member is dropped or
25
25
  * re-seeded rather than rendered.
26
- */ // 2: a draft is keyed by the tab address, and that grammar changed with
27
- // ADR 0042 — so every persisted draft is filed under a key nothing will ask for.
26
+ */ // 2: a draft is keyed by the tab address, and that grammar changed when the
27
+ // address became the taxonomy serialized — so every persisted draft is filed
28
+ // under a key nothing will ask for.
28
29
  export const DRAFTS_VERSION = 2;
29
30
  const emptyDrafts = {
30
31
  drafts: {}
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/lib/workspace/drafts-state.ts"],"sourcesContent":["'use client';\n\nimport { isJsonValue, type JsonValue } from '@entifix/core';\nimport { useCallback, useEffect } from 'react';\nimport { create } from 'zustand';\nimport { createJSONStorage, persist } from 'zustand/middleware';\n\nimport { makeIndexedDbStateStorage } from './idb-state-storage.js';\nimport { WORKSPACE_DB } from './tabs-state.js';\n\n/**\n * Per-address edit drafts, persisted to IndexedDB so an in-progress edit\n * survives a refresh. Keyed by the tab's `?tab=` param (its address), a draft is\n * whatever the editor needs to restore its fields; it exists until the user\n * saves (or discards). This is the \"continuous autosave\" seam — workspace-host\n * only, so a plain route stays ephemeral.\n *\n * **A draft is JSON round-trippable, period.** It is written through\n * `createJSONStorage`, so a class instance, an `EntityLink` or a `Date` does not\n * \"mostly survive\" — it comes back as something else, silently. {@link JsonValue}\n * is the compile-time half of that rule and {@link mergeDrafts} the runtime half.\n * Note that `UiPreferencesState`, the other persisted client store, writes\n * through IndexedDB's structured clone and *does* keep a `Date`; the two\n * contracts differ and must not be conflated.\n */\nexport interface DraftsState {\n drafts: Record<string, JsonValue>;\n setDraft(address: string, value: JsonValue): void;\n clearDraft(address: string): void;\n}\n\nexport function persistedDrafts(\n store: DraftsState,\n): Pick<DraftsState, 'drafts'> {\n return { drafts: store.drafts };\n}\n\nconst DRAFTS_STORE = 'stores';\n\n/**\n * Bump when the *envelope* changes — how a draft is keyed or wrapped — and every\n * persisted draft is discarded on the next load.\n *\n * It cannot see a change to one entity's members, because a draft is opaque\n * here: this store holds `JsonValue`, and only the form that wrote one knows\n * which members it should have. That half is `restoreEntityDraft`, which layers\n * a restored draft over a freshly seeded one so a drifted member is dropped or\n * re-seeded rather than rendered.\n */\n// 2: a draft is keyed by the tab address, and that grammar changed with\n// ADR 0042 — so every persisted draft is filed under a key nothing will ask for.\nexport const DRAFTS_VERSION = 2;\n\nconst emptyDrafts: Pick<DraftsState, 'drafts'> = { drafts: {} };\n\n/**\n * Discard, never guess. A draft whose envelope this build does not understand is\n * an unfinished edit, not a record — losing it costs a retype, while migrating\n * it blind risks submitting values whose meaning has changed.\n *\n * Written out rather than left to zustand's default, which discards too but logs\n * `State loaded from storage couldn't be migrated since no migrate function was\n * provided` while doing it — an error message for a decision that was made on\n * purpose.\n */\nexport function migrateDrafts(): Pick<DraftsState, 'drafts'> {\n return emptyDrafts;\n}\n\n/**\n * Restores only the entries that are actually JSON.\n *\n * The type above says what may be written; this is what happens when something\n * else was, by a build that predates the rule or by a `setDraft` reached through\n * a cast. Dropping the offending entry keeps one bad draft from taking the whole\n * workspace's drafts with it — and it is per entry rather than all-or-nothing\n * for that reason.\n */\nexport function mergeDrafts(\n persistedState: unknown,\n currentState: DraftsState,\n): DraftsState {\n const restored = (persistedState as Partial<DraftsState> | undefined)?.drafts;\n if (restored === null || typeof restored !== 'object') return currentState;\n\n const drafts: Record<string, JsonValue> = {};\n for (const [address, value] of Object.entries(restored)) {\n if (isJsonValue(value)) drafts[address] = value;\n }\n return { ...currentState, drafts };\n}\n\nexport const useDraftsState = create<DraftsState>()(\n persist(\n set => ({\n drafts: {},\n setDraft: (address, value) =>\n set(state => ({ drafts: { ...state.drafts, [address]: value } })),\n clearDraft: address =>\n set(state => {\n if (!(address in state.drafts)) return state;\n const { [address]: _removed, ...rest } = state.drafts;\n return { drafts: rest };\n }),\n }),\n {\n // Replaced with a scoped name by `WorkspaceShell` before it rehydrates, so\n // two accounts on one browser profile do not share drafts. This literal is\n // only what an unscoped store would write, and nothing rehydrates without\n // passing through that call.\n name: 'drafts',\n version: DRAFTS_VERSION,\n migrate: migrateDrafts,\n merge: mergeDrafts,\n storage: createJSONStorage(() =>\n makeIndexedDbStateStorage(WORKSPACE_DB, DRAFTS_STORE),\n ),\n partialize: persistedDrafts,\n skipHydration: true,\n },\n ),\n);\n\n/**\n * Bind an editor to its persisted draft. Returns the current draft (restored\n * from IndexedDB on mount), a setter the editor calls as fields change, and a\n * clear for when the edit is committed.\n *\n * `TDraft` is constrained to {@link JsonValue}: an editor that wants to autosave\n * something a JSON round trip would not return is asking for the one thing this\n * store cannot do, and the compile error is the whole point of the constraint.\n *\n * One sharp edge, and the error message does not explain it: **declare a draft\n * type as a `type`, never an `interface`.** TypeScript gives an interface no\n * implicit index signature, so even `interface Draft { name: string }` — as JSON\n * as a value gets — fails the constraint with \"Index signature for type 'string'\n * is missing\". A `type` alias of the same shape passes.\n */\nexport function useDraft<TDraft extends JsonValue>(address: string) {\n const draft = useDraftsState(\n state => state.drafts[address] as TDraft | undefined,\n );\n const set = useDraftsState(state => state.setDraft);\n const clear = useDraftsState(state => state.clearDraft);\n\n useEffect(() => {\n void useDraftsState.persist.rehydrate();\n }, []);\n\n const setDraft = useCallback(\n (value: TDraft) => set(address, value),\n [set, address],\n );\n const clearDraft = useCallback(() => clear(address), [clear, address]);\n\n return { draft, setDraft, clearDraft };\n}\n"],"names":["isJsonValue","useCallback","useEffect","create","createJSONStorage","persist","makeIndexedDbStateStorage","WORKSPACE_DB","persistedDrafts","store","drafts","DRAFTS_STORE","DRAFTS_VERSION","emptyDrafts","migrateDrafts","mergeDrafts","persistedState","currentState","restored","address","value","Object","entries","useDraftsState","set","setDraft","state","clearDraft","_removed","rest","name","version","migrate","merge","storage","partialize","skipHydration","useDraft","draft","clear","rehydrate"],"mappings":"AAAA;;;;AAEA,SAASA,WAAW,QAAwB,gBAAgB;AAC5D,SAASC,WAAW,EAAEC,SAAS,QAAQ,QAAQ;AAC/C,SAASC,MAAM,QAAQ,UAAU;AACjC,SAASC,iBAAiB,EAAEC,OAAO,QAAQ,qBAAqB;AAEhE,SAASC,yBAAyB,QAAQ,yBAAyB;AACnE,SAASC,YAAY,QAAQ,kBAAkB;AAuB/C,OAAO,SAASC,gBACdC,KAAkB;IAElB,OAAO;QAAEC,QAAQD,MAAMC,MAAM;IAAC;AAChC;AAEA,MAAMC,eAAe;AAErB;;;;;;;;;CASC,GACD,wEAAwE;AACxE,iFAAiF;AACjF,OAAO,MAAMC,iBAAiB,EAAE;AAEhC,MAAMC,cAA2C;IAAEH,QAAQ,CAAC;AAAE;AAE9D;;;;;;;;;CASC,GACD,OAAO,SAASI;IACd,OAAOD;AACT;AAEA;;;;;;;;CAQC,GACD,OAAO,SAASE,YACdC,cAAuB,EACvBC,YAAyB;IAEzB,MAAMC,WAAYF,kCAAD,AAACA,eAAqDN,MAAM;IAC7E,IAAIQ,aAAa,QAAQ,OAAOA,aAAa,UAAU,OAAOD;IAE9D,MAAMP,SAAoC,CAAC;IAC3C,KAAK,MAAM,CAACS,SAASC,MAAM,IAAIC,OAAOC,OAAO,CAACJ,UAAW;QACvD,IAAIlB,YAAYoB,QAAQV,MAAM,CAACS,QAAQ,GAAGC;IAC5C;IACA,OAAO,aAAKH;QAAcP;;AAC5B;AAEA,OAAO,MAAMa,iBAAiBpB,SAC5BE,QACEmB,CAAAA,MAAQ,CAAA;QACNd,QAAQ,CAAC;QACTe,UAAU,CAACN,SAASC,QAClBI,IAAIE,CAAAA,QAAU,CAAA;oBAAEhB,QAAQ,aAAKgB,MAAMhB,MAAM;wBAAE,CAACS,QAAQ,EAAEC;;gBAAQ,CAAA;QAChEO,YAAYR,CAAAA,UACVK,IAAIE,CAAAA;gBACF,IAAI,CAAEP,CAAAA,WAAWO,MAAMhB,MAAM,AAAD,GAAI,OAAOgB;gBACvC,MAAyCA,gBAAAA,MAAMhB,MAAM,EAA/C,EAAE,CAACS,QAAQ,EAAES,QAAQ,EAAW,GAAGF,eAATG,wCAASH;qCAAhCP;;gBACT,OAAO;oBAAET,QAAQmB;gBAAK;YACxB;IACJ,CAAA,GACA;IACE,2EAA2E;IAC3E,2EAA2E;IAC3E,0EAA0E;IAC1E,6BAA6B;IAC7BC,MAAM;IACNC,SAASnB;IACToB,SAASlB;IACTmB,OAAOlB;IACPmB,SAAS9B,kBAAkB,IACzBE,0BAA0BC,cAAcI;IAE1CwB,YAAY3B;IACZ4B,eAAe;AACjB,IAEF;AAEF;;;;;;;;;;;;;;CAcC,GACD,OAAO,SAASC,SAAmClB,OAAe;IAChE,MAAMmB,QAAQf,eACZG,CAAAA,QAASA,MAAMhB,MAAM,CAACS,QAAQ;IAEhC,MAAMK,MAAMD,eAAeG,CAAAA,QAASA,MAAMD,QAAQ;IAClD,MAAMc,QAAQhB,eAAeG,CAAAA,QAASA,MAAMC,UAAU;IAEtDzB,UAAU;QACR,KAAKqB,eAAelB,OAAO,CAACmC,SAAS;IACvC,GAAG,EAAE;IAEL,MAAMf,WAAWxB,YACf,CAACmB,QAAkBI,IAAIL,SAASC,QAChC;QAACI;QAAKL;KAAQ;IAEhB,MAAMQ,aAAa1B,YAAY,IAAMsC,MAAMpB,UAAU;QAACoB;QAAOpB;KAAQ;IAErE,OAAO;QAAEmB;QAAOb;QAAUE;IAAW;AACvC"}
1
+ {"version":3,"sources":["../../../src/lib/workspace/drafts-state.ts"],"sourcesContent":["'use client';\n\nimport { isJsonValue, type JsonValue } from '@entifix/core';\nimport { useCallback, useEffect } from 'react';\nimport { create } from 'zustand';\nimport { createJSONStorage, persist } from 'zustand/middleware';\n\nimport { makeIndexedDbStateStorage } from './idb-state-storage.js';\nimport { WORKSPACE_DB } from './tabs-state.js';\n\n/**\n * Per-address edit drafts, persisted to IndexedDB so an in-progress edit\n * survives a refresh. Keyed by the tab's `?tab=` param (its address), a draft is\n * whatever the editor needs to restore its fields; it exists until the user\n * saves (or discards). This is the \"continuous autosave\" seam — workspace-host\n * only, so a plain route stays ephemeral.\n *\n * **A draft is JSON round-trippable, period.** It is written through\n * `createJSONStorage`, so a class instance, an `EntityLink` or a `Date` does not\n * \"mostly survive\" — it comes back as something else, silently. {@link JsonValue}\n * is the compile-time half of that rule and {@link mergeDrafts} the runtime half.\n * Note that `UiPreferencesState`, the other persisted client store, writes\n * through IndexedDB's structured clone and *does* keep a `Date`; the two\n * contracts differ and must not be conflated.\n */\nexport interface DraftsState {\n drafts: Record<string, JsonValue>;\n setDraft(address: string, value: JsonValue): void;\n clearDraft(address: string): void;\n}\n\nexport function persistedDrafts(\n store: DraftsState,\n): Pick<DraftsState, 'drafts'> {\n return { drafts: store.drafts };\n}\n\nconst DRAFTS_STORE = 'stores';\n\n/**\n * Bump when the *envelope* changes — how a draft is keyed or wrapped — and every\n * persisted draft is discarded on the next load.\n *\n * It cannot see a change to one entity's members, because a draft is opaque\n * here: this store holds `JsonValue`, and only the form that wrote one knows\n * which members it should have. That half is `restoreEntityDraft`, which layers\n * a restored draft over a freshly seeded one so a drifted member is dropped or\n * re-seeded rather than rendered.\n */\n// 2: a draft is keyed by the tab address, and that grammar changed when the\n// address became the taxonomy serialized — so every persisted draft is filed\n// under a key nothing will ask for.\nexport const DRAFTS_VERSION = 2;\n\nconst emptyDrafts: Pick<DraftsState, 'drafts'> = { drafts: {} };\n\n/**\n * Discard, never guess. A draft whose envelope this build does not understand is\n * an unfinished edit, not a record — losing it costs a retype, while migrating\n * it blind risks submitting values whose meaning has changed.\n *\n * Written out rather than left to zustand's default, which discards too but logs\n * `State loaded from storage couldn't be migrated since no migrate function was\n * provided` while doing it — an error message for a decision that was made on\n * purpose.\n */\nexport function migrateDrafts(): Pick<DraftsState, 'drafts'> {\n return emptyDrafts;\n}\n\n/**\n * Restores only the entries that are actually JSON.\n *\n * The type above says what may be written; this is what happens when something\n * else was, by a build that predates the rule or by a `setDraft` reached through\n * a cast. Dropping the offending entry keeps one bad draft from taking the whole\n * workspace's drafts with it — and it is per entry rather than all-or-nothing\n * for that reason.\n */\nexport function mergeDrafts(\n persistedState: unknown,\n currentState: DraftsState,\n): DraftsState {\n const restored = (persistedState as Partial<DraftsState> | undefined)?.drafts;\n if (restored === null || typeof restored !== 'object') return currentState;\n\n const drafts: Record<string, JsonValue> = {};\n for (const [address, value] of Object.entries(restored)) {\n if (isJsonValue(value)) drafts[address] = value;\n }\n return { ...currentState, drafts };\n}\n\nexport const useDraftsState = create<DraftsState>()(\n persist(\n set => ({\n drafts: {},\n setDraft: (address, value) =>\n set(state => ({ drafts: { ...state.drafts, [address]: value } })),\n clearDraft: address =>\n set(state => {\n if (!(address in state.drafts)) return state;\n const { [address]: _removed, ...rest } = state.drafts;\n return { drafts: rest };\n }),\n }),\n {\n // Replaced with a scoped name by `WorkspaceShell` before it rehydrates, so\n // two accounts on one browser profile do not share drafts. This literal is\n // only what an unscoped store would write, and nothing rehydrates without\n // passing through that call.\n name: 'drafts',\n version: DRAFTS_VERSION,\n migrate: migrateDrafts,\n merge: mergeDrafts,\n storage: createJSONStorage(() =>\n makeIndexedDbStateStorage(WORKSPACE_DB, DRAFTS_STORE),\n ),\n partialize: persistedDrafts,\n skipHydration: true,\n },\n ),\n);\n\n/**\n * Bind an editor to its persisted draft. Returns the current draft (restored\n * from IndexedDB on mount), a setter the editor calls as fields change, and a\n * clear for when the edit is committed.\n *\n * `TDraft` is constrained to {@link JsonValue}: an editor that wants to autosave\n * something a JSON round trip would not return is asking for the one thing this\n * store cannot do, and the compile error is the whole point of the constraint.\n *\n * One sharp edge, and the error message does not explain it: **declare a draft\n * type as a `type`, never an `interface`.** TypeScript gives an interface no\n * implicit index signature, so even `interface Draft { name: string }` — as JSON\n * as a value gets — fails the constraint with \"Index signature for type 'string'\n * is missing\". A `type` alias of the same shape passes.\n */\nexport function useDraft<TDraft extends JsonValue>(address: string) {\n const draft = useDraftsState(\n state => state.drafts[address] as TDraft | undefined,\n );\n const set = useDraftsState(state => state.setDraft);\n const clear = useDraftsState(state => state.clearDraft);\n\n useEffect(() => {\n void useDraftsState.persist.rehydrate();\n }, []);\n\n const setDraft = useCallback(\n (value: TDraft) => set(address, value),\n [set, address],\n );\n const clearDraft = useCallback(() => clear(address), [clear, address]);\n\n return { draft, setDraft, clearDraft };\n}\n"],"names":["isJsonValue","useCallback","useEffect","create","createJSONStorage","persist","makeIndexedDbStateStorage","WORKSPACE_DB","persistedDrafts","store","drafts","DRAFTS_STORE","DRAFTS_VERSION","emptyDrafts","migrateDrafts","mergeDrafts","persistedState","currentState","restored","address","value","Object","entries","useDraftsState","set","setDraft","state","clearDraft","_removed","rest","name","version","migrate","merge","storage","partialize","skipHydration","useDraft","draft","clear","rehydrate"],"mappings":"AAAA;;;;AAEA,SAASA,WAAW,QAAwB,gBAAgB;AAC5D,SAASC,WAAW,EAAEC,SAAS,QAAQ,QAAQ;AAC/C,SAASC,MAAM,QAAQ,UAAU;AACjC,SAASC,iBAAiB,EAAEC,OAAO,QAAQ,qBAAqB;AAEhE,SAASC,yBAAyB,QAAQ,yBAAyB;AACnE,SAASC,YAAY,QAAQ,kBAAkB;AAuB/C,OAAO,SAASC,gBACdC,KAAkB;IAElB,OAAO;QAAEC,QAAQD,MAAMC,MAAM;IAAC;AAChC;AAEA,MAAMC,eAAe;AAErB;;;;;;;;;CASC,GACD,4EAA4E;AAC5E,6EAA6E;AAC7E,oCAAoC;AACpC,OAAO,MAAMC,iBAAiB,EAAE;AAEhC,MAAMC,cAA2C;IAAEH,QAAQ,CAAC;AAAE;AAE9D;;;;;;;;;CASC,GACD,OAAO,SAASI;IACd,OAAOD;AACT;AAEA;;;;;;;;CAQC,GACD,OAAO,SAASE,YACdC,cAAuB,EACvBC,YAAyB;IAEzB,MAAMC,WAAYF,kCAAD,AAACA,eAAqDN,MAAM;IAC7E,IAAIQ,aAAa,QAAQ,OAAOA,aAAa,UAAU,OAAOD;IAE9D,MAAMP,SAAoC,CAAC;IAC3C,KAAK,MAAM,CAACS,SAASC,MAAM,IAAIC,OAAOC,OAAO,CAACJ,UAAW;QACvD,IAAIlB,YAAYoB,QAAQV,MAAM,CAACS,QAAQ,GAAGC;IAC5C;IACA,OAAO,aAAKH;QAAcP;;AAC5B;AAEA,OAAO,MAAMa,iBAAiBpB,SAC5BE,QACEmB,CAAAA,MAAQ,CAAA;QACNd,QAAQ,CAAC;QACTe,UAAU,CAACN,SAASC,QAClBI,IAAIE,CAAAA,QAAU,CAAA;oBAAEhB,QAAQ,aAAKgB,MAAMhB,MAAM;wBAAE,CAACS,QAAQ,EAAEC;;gBAAQ,CAAA;QAChEO,YAAYR,CAAAA,UACVK,IAAIE,CAAAA;gBACF,IAAI,CAAEP,CAAAA,WAAWO,MAAMhB,MAAM,AAAD,GAAI,OAAOgB;gBACvC,MAAyCA,gBAAAA,MAAMhB,MAAM,EAA/C,EAAE,CAACS,QAAQ,EAAES,QAAQ,EAAW,GAAGF,eAATG,wCAASH;qCAAhCP;;gBACT,OAAO;oBAAET,QAAQmB;gBAAK;YACxB;IACJ,CAAA,GACA;IACE,2EAA2E;IAC3E,2EAA2E;IAC3E,0EAA0E;IAC1E,6BAA6B;IAC7BC,MAAM;IACNC,SAASnB;IACToB,SAASlB;IACTmB,OAAOlB;IACPmB,SAAS9B,kBAAkB,IACzBE,0BAA0BC,cAAcI;IAE1CwB,YAAY3B;IACZ4B,eAAe;AACjB,IAEF;AAEF;;;;;;;;;;;;;;CAcC,GACD,OAAO,SAASC,SAAmClB,OAAe;IAChE,MAAMmB,QAAQf,eACZG,CAAAA,QAASA,MAAMhB,MAAM,CAACS,QAAQ;IAEhC,MAAMK,MAAMD,eAAeG,CAAAA,QAASA,MAAMD,QAAQ;IAClD,MAAMc,QAAQhB,eAAeG,CAAAA,QAASA,MAAMC,UAAU;IAEtDzB,UAAU;QACR,KAAKqB,eAAelB,OAAO,CAACmC,SAAS;IACvC,GAAG,EAAE;IAEL,MAAMf,WAAWxB,YACf,CAACmB,QAAkBI,IAAIL,SAASC,QAChC;QAACI;QAAKL;KAAQ;IAEhB,MAAMQ,aAAa1B,YAAY,IAAMsC,MAAMpB,UAAU;QAACoB;QAAOpB;KAAQ;IAErE,OAAO;QAAEmB;QAAOb;QAAUE;IAAW;AACvC"}
@@ -24,8 +24,7 @@ export interface EntityTabScreens {
24
24
  * **The list and the record are not different kinds.** They are the same screen
25
25
  * with and without a record, which is exactly what the optional id in the
26
26
  * payload says — three kinds (`catalog:`, `entity:`, `system:`) collapsed into
27
- * one for that reason, and the prefix names the *taxonomy* now
28
- * ([ADR 0042](../../../../../../../docs/adr/0042-the-workspace-address-is-the-taxonomy-serialized.md)).
27
+ * one for that reason, and the prefix names the *taxonomy* now.
29
28
  *
30
29
  * A factory over the {@link ScreenType} rather than a constant, and that is the
31
30
  * whole of what makes an Operaciones screen addressable. It was written inline
@@ -1 +1 @@
1
- {"version":3,"file":"entity-tab-kind.d.ts","sourceRoot":"","sources":["../../../src/lib/workspace/entity-tab-kind.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAsB,KAAK,UAAU,EAAE,MAAM,gBAAgB,CAAC;AACrE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAIvC,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,eAAe,CAAC;AAiB7C,uEAAuE;AACvE,MAAM,WAAW,aAAa;IAC5B,sFAAsF;IACtF,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,MAAM,IAAI,SAAS,CAAC;CACrB;AAED,qEAAqE;AACrE,MAAM,WAAW,eAAe;IAC9B,qFAAqF;IACrF,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,SAAS,CAAC;CAC/B;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC;IAC9C,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC;CACnD;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,aAAa,CAC3B,IAAI,EAAE,UAAU,EAChB,OAAO,EAAE,gBAAgB,GACxB,OAAO,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,EAAE,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CA+BvC"}
1
+ {"version":3,"file":"entity-tab-kind.d.ts","sourceRoot":"","sources":["../../../src/lib/workspace/entity-tab-kind.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAsB,KAAK,UAAU,EAAE,MAAM,gBAAgB,CAAC;AACrE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAIvC,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,eAAe,CAAC;AAiB7C,uEAAuE;AACvE,MAAM,WAAW,aAAa;IAC5B,sFAAsF;IACtF,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,MAAM,IAAI,SAAS,CAAC;CACrB;AAED,qEAAqE;AACrE,MAAM,WAAW,eAAe;IAC9B,qFAAqF;IACrF,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,SAAS,CAAC;CAC/B;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC;IAC9C,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC;CACnD;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,aAAa,CAC3B,IAAI,EAAE,UAAU,EAChB,OAAO,EAAE,gBAAgB,GACxB,OAAO,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,EAAE,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CA+BvC"}
@@ -22,8 +22,7 @@ import { EntityNavProvider, useTabEntityNav } from './entity-nav.js';
22
22
  * **The list and the record are not different kinds.** They are the same screen
23
23
  * with and without a record, which is exactly what the optional id in the
24
24
  * payload says — three kinds (`catalog:`, `entity:`, `system:`) collapsed into
25
- * one for that reason, and the prefix names the *taxonomy* now
26
- * ([ADR 0042](../../../../../../../docs/adr/0042-the-workspace-address-is-the-taxonomy-serialized.md)).
25
+ * one for that reason, and the prefix names the *taxonomy* now.
27
26
  *
28
27
  * A factory over the {@link ScreenType} rather than a constant, and that is the
29
28
  * whole of what makes an Operaciones screen addressable. It was written inline
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/lib/workspace/entity-tab-kind.tsx"],"sourcesContent":["'use client';\n\nimport { parseScreenPayload, type ScreenType } from '@entifix/authz';\nimport type { ReactNode } from 'react';\n\nimport { CATALOG_NEW_SLUG } from '../crud/slug.js';\nimport { EntityNavProvider, useTabEntityNav } from './entity-nav.js';\nimport type { TabKind } from './tab-kind.js';\n\n/**\n * Makes every page rendered in a tab of this kind navigate by tab.\n *\n * A generated page asks {@link useEntityNavHost} whether a host is mounted; with\n * this around it, opening a record or going back opens a tab of the same kind\n * rather than following the link out of the workspace.\n */\nfunction TabNav({ type, children }: { type: ScreenType; children: ReactNode }) {\n return (\n <EntityNavProvider value={useTabEntityNav(type)}>\n {children}\n </EntityNavProvider>\n );\n}\n\n/** One list a workspace may open — the collection half of a screen. */\nexport interface EntityTabList {\n /** The tab caption's catalog key. `@entity({ pluralKey })` for a generated screen. */\n readonly titleKey: string;\n render(): ReactNode;\n}\n\n/** One record a workspace may open — the same screen, with an id. */\nexport interface EntityTabRecord {\n /** The tab caption's catalog key. `@entity({ labelKey })` for a generated screen. */\n readonly labelKey: string;\n render(id: string): ReactNode;\n}\n\nexport interface EntityTabScreens {\n readonly lists: Record<string, EntityTabList>;\n readonly records: Record<string, EntityTabRecord>;\n}\n\n/**\n * An entity tab kind: `<type>:<key>` for a list, `<type>:<key>:<id>` for one\n * record.\n *\n * **The list and the record are not different kinds.** They are the same screen\n * with and without a record, which is exactly what the optional id in the\n * payload says — three kinds (`catalog:`, `entity:`, `system:`) collapsed into\n * one for that reason, and the prefix names the *taxonomy* now\n * ([ADR 0042](../../../../../../../docs/adr/0042-the-workspace-address-is-the-taxonomy-serialized.md)).\n *\n * A factory over the {@link ScreenType} rather than a constant, and that is the\n * whole of what makes an Operaciones screen addressable. It was written inline\n * in back-office-app as a `masterKind`, so `master:` was the only entity\n * address any host could resolve and `operation:` matched nothing at all — a\n * second copy of this parser beside it is exactly the drift the registry was\n * consolidated to stop, and the two would have had to agree about a grammar\n * neither owns.\n *\n * Which screens a host offers stays the host's decision — a second host\n * mounting the same shells may want a different set — so the screens arrive as\n * an argument. What no longer varies is how one is addressed.\n *\n * `render` takes the id rather than a component, so the caller decides what\n * hosting a record means: in a workspace that is an editor wired to an\n * address-keyed autosaved draft, and the address it keys on has to carry this\n * same `type`.\n */\nexport function entityTabKind(\n type: ScreenType,\n screens: EntityTabScreens,\n): TabKind<{ key: string; id?: string }> {\n return {\n kind: type,\n match: payload => {\n const parsed = parseScreenPayload(payload);\n if (parsed === null) return null;\n // A key is valid for the half it names: a record address for a screen\n // with a list and no record editor resolves to nothing, and the workspace\n // answers with its fallback rather than rendering an empty editor.\n const known =\n parsed.id === undefined\n ? parsed.key in screens.lists\n : parsed.key in screens.records;\n return known ? parsed : null;\n },\n toParam: addr =>\n addr.id === undefined ? addr.key : `${addr.key}:${addr.id}`,\n title: (addr, translate) =>\n addr.id === undefined\n ? translate(screens.lists[addr.key].titleKey)\n : addr.id === CATALOG_NEW_SLUG\n ? `${translate(screens.records[addr.key].labelKey)} · ${translate('shell:breadcrumbs.new')}`\n : `${translate(screens.records[addr.key].labelKey)} #${addr.id}`,\n render: addr => (\n <TabNav type={type}>\n {addr.id === undefined\n ? screens.lists[addr.key].render()\n : screens.records[addr.key].render(addr.id)}\n </TabNav>\n ),\n };\n}\n"],"names":["parseScreenPayload","CATALOG_NEW_SLUG","EntityNavProvider","useTabEntityNav","TabNav","type","children","value","entityTabKind","screens","kind","match","payload","parsed","known","id","undefined","key","lists","records","toParam","addr","title","translate","titleKey","labelKey","render"],"mappings":"AAAA;;AAEA,SAASA,kBAAkB,QAAyB,iBAAiB;AAGrE,SAASC,gBAAgB,QAAQ,kBAAkB;AACnD,SAASC,iBAAiB,EAAEC,eAAe,QAAQ,kBAAkB;AAGrE;;;;;;CAMC,GACD,SAASC,OAAO,EAAEC,IAAI,EAAEC,QAAQ,EAA6C;IAC3E,qBACE,KAACJ;QAAkBK,OAAOJ,gBAAgBE;kBACvCC;;AAGP;AAqBA;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GACD,OAAO,SAASE,cACdH,IAAgB,EAChBI,OAAyB;IAEzB,OAAO;QACLC,MAAML;QACNM,OAAOC,CAAAA;YACL,MAAMC,SAASb,mBAAmBY;YAClC,IAAIC,WAAW,MAAM,OAAO;YAC5B,sEAAsE;YACtE,0EAA0E;YAC1E,mEAAmE;YACnE,MAAMC,QACJD,OAAOE,EAAE,KAAKC,YACVH,OAAOI,GAAG,IAAIR,QAAQS,KAAK,GAC3BL,OAAOI,GAAG,IAAIR,QAAQU,OAAO;YACnC,OAAOL,QAAQD,SAAS;QAC1B;QACAO,SAASC,CAAAA,OACPA,KAAKN,EAAE,KAAKC,YAAYK,KAAKJ,GAAG,GAAG,GAAGI,KAAKJ,GAAG,CAAC,CAAC,EAAEI,KAAKN,EAAE,EAAE;QAC7DO,OAAO,CAACD,MAAME,YACZF,KAAKN,EAAE,KAAKC,YACRO,UAAUd,QAAQS,KAAK,CAACG,KAAKJ,GAAG,CAAC,CAACO,QAAQ,IAC1CH,KAAKN,EAAE,KAAKd,mBACV,GAAGsB,UAAUd,QAAQU,OAAO,CAACE,KAAKJ,GAAG,CAAC,CAACQ,QAAQ,EAAE,GAAG,EAAEF,UAAU,0BAA0B,GAC1F,GAAGA,UAAUd,QAAQU,OAAO,CAACE,KAAKJ,GAAG,CAAC,CAACQ,QAAQ,EAAE,EAAE,EAAEJ,KAAKN,EAAE,EAAE;QACtEW,QAAQL,CAAAA,qBACN,KAACjB;gBAAOC,MAAMA;0BACXgB,KAAKN,EAAE,KAAKC,YACTP,QAAQS,KAAK,CAACG,KAAKJ,GAAG,CAAC,CAACS,MAAM,KAC9BjB,QAAQU,OAAO,CAACE,KAAKJ,GAAG,CAAC,CAACS,MAAM,CAACL,KAAKN,EAAE;;IAGlD;AACF"}
1
+ {"version":3,"sources":["../../../src/lib/workspace/entity-tab-kind.tsx"],"sourcesContent":["'use client';\n\nimport { parseScreenPayload, type ScreenType } from '@entifix/authz';\nimport type { ReactNode } from 'react';\n\nimport { CATALOG_NEW_SLUG } from '../crud/slug.js';\nimport { EntityNavProvider, useTabEntityNav } from './entity-nav.js';\nimport type { TabKind } from './tab-kind.js';\n\n/**\n * Makes every page rendered in a tab of this kind navigate by tab.\n *\n * A generated page asks {@link useEntityNavHost} whether a host is mounted; with\n * this around it, opening a record or going back opens a tab of the same kind\n * rather than following the link out of the workspace.\n */\nfunction TabNav({ type, children }: { type: ScreenType; children: ReactNode }) {\n return (\n <EntityNavProvider value={useTabEntityNav(type)}>\n {children}\n </EntityNavProvider>\n );\n}\n\n/** One list a workspace may open — the collection half of a screen. */\nexport interface EntityTabList {\n /** The tab caption's catalog key. `@entity({ pluralKey })` for a generated screen. */\n readonly titleKey: string;\n render(): ReactNode;\n}\n\n/** One record a workspace may open — the same screen, with an id. */\nexport interface EntityTabRecord {\n /** The tab caption's catalog key. `@entity({ labelKey })` for a generated screen. */\n readonly labelKey: string;\n render(id: string): ReactNode;\n}\n\nexport interface EntityTabScreens {\n readonly lists: Record<string, EntityTabList>;\n readonly records: Record<string, EntityTabRecord>;\n}\n\n/**\n * An entity tab kind: `<type>:<key>` for a list, `<type>:<key>:<id>` for one\n * record.\n *\n * **The list and the record are not different kinds.** They are the same screen\n * with and without a record, which is exactly what the optional id in the\n * payload says — three kinds (`catalog:`, `entity:`, `system:`) collapsed into\n * one for that reason, and the prefix names the *taxonomy* now.\n *\n * A factory over the {@link ScreenType} rather than a constant, and that is the\n * whole of what makes an Operaciones screen addressable. It was written inline\n * in back-office-app as a `masterKind`, so `master:` was the only entity\n * address any host could resolve and `operation:` matched nothing at all — a\n * second copy of this parser beside it is exactly the drift the registry was\n * consolidated to stop, and the two would have had to agree about a grammar\n * neither owns.\n *\n * Which screens a host offers stays the host's decision — a second host\n * mounting the same shells may want a different set — so the screens arrive as\n * an argument. What no longer varies is how one is addressed.\n *\n * `render` takes the id rather than a component, so the caller decides what\n * hosting a record means: in a workspace that is an editor wired to an\n * address-keyed autosaved draft, and the address it keys on has to carry this\n * same `type`.\n */\nexport function entityTabKind(\n type: ScreenType,\n screens: EntityTabScreens,\n): TabKind<{ key: string; id?: string }> {\n return {\n kind: type,\n match: payload => {\n const parsed = parseScreenPayload(payload);\n if (parsed === null) return null;\n // A key is valid for the half it names: a record address for a screen\n // with a list and no record editor resolves to nothing, and the workspace\n // answers with its fallback rather than rendering an empty editor.\n const known =\n parsed.id === undefined\n ? parsed.key in screens.lists\n : parsed.key in screens.records;\n return known ? parsed : null;\n },\n toParam: addr =>\n addr.id === undefined ? addr.key : `${addr.key}:${addr.id}`,\n title: (addr, translate) =>\n addr.id === undefined\n ? translate(screens.lists[addr.key].titleKey)\n : addr.id === CATALOG_NEW_SLUG\n ? `${translate(screens.records[addr.key].labelKey)} · ${translate('shell:breadcrumbs.new')}`\n : `${translate(screens.records[addr.key].labelKey)} #${addr.id}`,\n render: addr => (\n <TabNav type={type}>\n {addr.id === undefined\n ? screens.lists[addr.key].render()\n : screens.records[addr.key].render(addr.id)}\n </TabNav>\n ),\n };\n}\n"],"names":["parseScreenPayload","CATALOG_NEW_SLUG","EntityNavProvider","useTabEntityNav","TabNav","type","children","value","entityTabKind","screens","kind","match","payload","parsed","known","id","undefined","key","lists","records","toParam","addr","title","translate","titleKey","labelKey","render"],"mappings":"AAAA;;AAEA,SAASA,kBAAkB,QAAyB,iBAAiB;AAGrE,SAASC,gBAAgB,QAAQ,kBAAkB;AACnD,SAASC,iBAAiB,EAAEC,eAAe,QAAQ,kBAAkB;AAGrE;;;;;;CAMC,GACD,SAASC,OAAO,EAAEC,IAAI,EAAEC,QAAQ,EAA6C;IAC3E,qBACE,KAACJ;QAAkBK,OAAOJ,gBAAgBE;kBACvCC;;AAGP;AAqBA;;;;;;;;;;;;;;;;;;;;;;;;;CAyBC,GACD,OAAO,SAASE,cACdH,IAAgB,EAChBI,OAAyB;IAEzB,OAAO;QACLC,MAAML;QACNM,OAAOC,CAAAA;YACL,MAAMC,SAASb,mBAAmBY;YAClC,IAAIC,WAAW,MAAM,OAAO;YAC5B,sEAAsE;YACtE,0EAA0E;YAC1E,mEAAmE;YACnE,MAAMC,QACJD,OAAOE,EAAE,KAAKC,YACVH,OAAOI,GAAG,IAAIR,QAAQS,KAAK,GAC3BL,OAAOI,GAAG,IAAIR,QAAQU,OAAO;YACnC,OAAOL,QAAQD,SAAS;QAC1B;QACAO,SAASC,CAAAA,OACPA,KAAKN,EAAE,KAAKC,YAAYK,KAAKJ,GAAG,GAAG,GAAGI,KAAKJ,GAAG,CAAC,CAAC,EAAEI,KAAKN,EAAE,EAAE;QAC7DO,OAAO,CAACD,MAAME,YACZF,KAAKN,EAAE,KAAKC,YACRO,UAAUd,QAAQS,KAAK,CAACG,KAAKJ,GAAG,CAAC,CAACO,QAAQ,IAC1CH,KAAKN,EAAE,KAAKd,mBACV,GAAGsB,UAAUd,QAAQU,OAAO,CAACE,KAAKJ,GAAG,CAAC,CAACQ,QAAQ,EAAE,GAAG,EAAEF,UAAU,0BAA0B,GAC1F,GAAGA,UAAUd,QAAQU,OAAO,CAACE,KAAKJ,GAAG,CAAC,CAACQ,QAAQ,EAAE,EAAE,EAAEJ,KAAKN,EAAE,EAAE;QACtEW,QAAQL,CAAAA,qBACN,KAACjB;gBAAOC,MAAMA;0BACXgB,KAAKN,EAAE,KAAKC,YACTP,QAAQS,KAAK,CAACG,KAAKJ,GAAG,CAAC,CAACS,MAAM,KAC9BjB,QAAQU,OAAO,CAACE,KAAKJ,GAAG,CAAC,CAACS,MAAM,CAACL,KAAKN,EAAE;;IAGlD;AACF"}
@@ -7,10 +7,11 @@ import type { PendingEntry, PendingTransaction } from '@entifix/transactions';
7
7
  * and drafts belong to the workspace and are scoped by `WorkspaceShell`; a
8
8
  * pending write is not, because a create happens at `/<basePath>/new` on the
9
9
  * plain route, outside any `WorkspaceShell` (a generated list still uses plain
10
- * `href`s — ADR 0042's own residual). A store mounted inside the workspace would
10
+ * `href`s — a known residual of the address grammar). A store mounted inside
11
+ * the workspace would
11
12
  * be written to by nothing, or — worse — written to *before* `persist.setOptions`
12
- * had scoped it, which is the unscoped cross-account restore ADR 0032 exists to
13
- * prevent. So `PendingTransactionsProvider` sits at the authenticated layout
13
+ * had scoped it, which is the unscoped cross-account restore that keying a
14
+ * draft per principal exists to prevent. So `PendingTransactionsProvider` sits at the authenticated layout
14
15
  * instead, above both.
15
16
  *
16
17
  * It shares the workspace's IndexedDB database because it is the same browser
@@ -27,7 +28,7 @@ export interface PendingState {
27
28
  /**
28
29
  * ⚠️ **`record` is stripped before persisting.** It is a class instance, and a
29
30
  * draft-style JSON round trip does not preserve one — it returns as something
30
- * else, silently (ADR 0032). So a refresh keeps the *watch* and loses the row,
31
+ * else, silently. So a refresh keeps the *watch* and loses the row,
31
32
  * which is the documented behaviour: past a refresh, server truth plus an honest
32
33
  * notice beats a record rebuilt from a blob.
33
34
  */
@@ -1 +1 @@
1
- {"version":3,"file":"pending-state.d.ts","sourceRoot":"","sources":["../../../src/lib/workspace/pending-state.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AAO9E;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC;IACtC,KAAK,CAAC,WAAW,EAAE,kBAAkB,GAAG,IAAI,CAAC;IAC7C,MAAM,CAAC,aAAa,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,GAAG,OAAO,CAAC;IACxD,MAAM,CAAC,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IACpC,IAAI,CAAC,aAAa,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACnD,OAAO,CAAC,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;CACtC;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,YAAY,GAClB,IAAI,CAAC,YAAY,EAAE,SAAS,CAAC,CAQ/B;AAID;;;;;;;;;;GAUG;AACH,eAAO,MAAM,WAAW,KAAK,CAAC;AAE9B;;;;;;;;;GASG;AACH,eAAO,MAAM,eAAe,IAAI,CAAC;AAIjC;;;;GAIG;AACH,wBAAgB,cAAc,IAAI,IAAI,CAAC,YAAY,EAAE,SAAS,CAAC,CAE9D;AAcD;;;;;GAKG;AACH,wBAAgB,YAAY,CAC1B,cAAc,EAAE,OAAO,EACvB,YAAY,EAAE,YAAY,GACzB,YAAY,CAUd;AAgBD,eAAO,MAAM,eAAe;;;;;;;;;;;;EAgE3B,CAAC"}
1
+ {"version":3,"file":"pending-state.d.ts","sourceRoot":"","sources":["../../../src/lib/workspace/pending-state.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AAO9E;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC;IACtC,KAAK,CAAC,WAAW,EAAE,kBAAkB,GAAG,IAAI,CAAC;IAC7C,MAAM,CAAC,aAAa,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,GAAG,OAAO,CAAC;IACxD,MAAM,CAAC,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IACpC,IAAI,CAAC,aAAa,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACnD,OAAO,CAAC,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;CACtC;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,YAAY,GAClB,IAAI,CAAC,YAAY,EAAE,SAAS,CAAC,CAQ/B;AAID;;;;;;;;;;GAUG;AACH,eAAO,MAAM,WAAW,KAAK,CAAC;AAE9B;;;;;;;;;GASG;AACH,eAAO,MAAM,eAAe,IAAI,CAAC;AAIjC;;;;GAIG;AACH,wBAAgB,cAAc,IAAI,IAAI,CAAC,YAAY,EAAE,SAAS,CAAC,CAE9D;AAcD;;;;;GAKG;AACH,wBAAgB,YAAY,CAC1B,cAAc,EAAE,OAAO,EACvB,YAAY,EAAE,YAAY,GACzB,YAAY,CAUd;AAgBD,eAAO,MAAM,eAAe;;;;;;;;;;;;EAgE3B,CAAC"}
@@ -9,7 +9,7 @@ import { WORKSPACE_DB } from './tabs-state.js';
9
9
  /**
10
10
  * ⚠️ **`record` is stripped before persisting.** It is a class instance, and a
11
11
  * draft-style JSON round trip does not preserve one — it returns as something
12
- * else, silently (ADR 0032). So a refresh keeps the *watch* and loses the row,
12
+ * else, silently. So a refresh keeps the *watch* and loses the row,
13
13
  * which is the documented behaviour: past a refresh, server truth plus an honest
14
14
  * notice beats a record rebuilt from a blob.
15
15
  */ export function persistedPending(store) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/lib/workspace/pending-state.ts"],"sourcesContent":["'use client';\n\nimport type { PendingEntry, PendingTransaction } from '@entifix/transactions';\nimport { create } from 'zustand';\nimport { createJSONStorage, persist } from 'zustand/middleware';\n\nimport { makeIndexedDbStateStorage } from './idb-state-storage.js';\nimport { WORKSPACE_DB } from './tabs-state.js';\n\n/**\n * Writes the browser started and the server has not finished, keyed by\n * transaction id.\n *\n * ⚠️ **This is not workspace state, and the distinction is load-bearing.** Tabs\n * and drafts belong to the workspace and are scoped by `WorkspaceShell`; a\n * pending write is not, because a create happens at `/<basePath>/new` on the\n * plain route, outside any `WorkspaceShell` (a generated list still uses plain\n * `href`s — ADR 0042's own residual). A store mounted inside the workspace would\n * be written to by nothing, or — worse — written to *before* `persist.setOptions`\n * had scoped it, which is the unscoped cross-account restore ADR 0032 exists to\n * prevent. So `PendingTransactionsProvider` sits at the authenticated layout\n * instead, above both.\n *\n * It shares the workspace's IndexedDB database because it is the same browser\n * profile and the same scope key, not because it is the same concern.\n */\nexport interface PendingState {\n pending: Record<string, PendingEntry>;\n began(transaction: PendingTransaction): void;\n attach(transactionId: string, record: unknown): boolean;\n settle(transactionId: string): void;\n fail(transactionId: string, reason?: string): void;\n dismiss(transactionId: string): void;\n}\n\n/**\n * ⚠️ **`record` is stripped before persisting.** It is a class instance, and a\n * draft-style JSON round trip does not preserve one — it returns as something\n * else, silently (ADR 0032). So a refresh keeps the *watch* and loses the row,\n * which is the documented behaviour: past a refresh, server truth plus an honest\n * notice beats a record rebuilt from a blob.\n */\nexport function persistedPending(\n store: PendingState,\n): Pick<PendingState, 'pending'> {\n const pending: Record<string, PendingEntry> = {};\n for (const [transactionId, { record: _dropped, ...rest }] of Object.entries(\n store.pending,\n )) {\n pending[transactionId] = rest;\n }\n return { pending };\n}\n\nconst PENDING_STORE = 'stores';\n\n/**\n * How many pending writes are watched at once, oldest evicted first.\n *\n * The same reasoning as {@link MAX_TABS}: an unbounded set persisted to\n * IndexedDB grows without anything ever pruning it, and reconciliation asks the\n * tracker once per entry on every reconnect — so the cap is also what keeps that\n * fan-out a handful of requests rather than an unbounded one.\n *\n * An evicted entry is dropped silently. The record is still on the server and\n * the list's own query is the fallback; what is lost is only the watching.\n */\nexport const MAX_PENDING = 12;\n\n/**\n * Bump when the *shape* of an entry changes, and every persisted pending write\n * is discarded on the next load.\n *\n * Discarding is right here for a reason the other two stores do not share: an\n * entry is a claim that something is still in flight, and it stops being true on\n * its own. A stale one restored from a previous build watches a transaction that\n * settled hours ago, and the reconcile that would correct it is keyed on a shape\n * this build may no longer understand.\n */\nexport const PENDING_VERSION = 1;\n\nconst emptyPending: Pick<PendingState, 'pending'> = { pending: {} };\n\n/**\n * Discard, never guess — written out rather than left to zustand's default,\n * which discards too but logs an error for a decision that was made on purpose.\n * Same call as `migrateDrafts` and `migrateTabs`.\n */\nexport function migratePending(): Pick<PendingState, 'pending'> {\n return emptyPending;\n}\n\n/** Whether a restored value is an entry this build can act on. */\nfunction isPendingEntry(value: unknown): value is PendingEntry {\n if (value === null || typeof value !== 'object') return false;\n const entry = value as Partial<PendingEntry>;\n return (\n typeof entry.transactionId === 'string' &&\n typeof entry.entity === 'string' &&\n typeof entry.at === 'string' &&\n (entry.state === 'pending' || entry.state === 'failed')\n );\n}\n\n/**\n * Restores only the entries that are actually entries — per entry, so one bad\n * record does not take the rest with it. The `mergeDrafts` rule, for the same\n * reason: this is what happens when something else was written by a build that\n * predates the shape.\n */\nexport function mergePending(\n persistedState: unknown,\n currentState: PendingState,\n): PendingState {\n const restored = (persistedState as Partial<PendingState> | undefined)\n ?.pending;\n if (restored === null || typeof restored !== 'object') return currentState;\n\n const pending: Record<string, PendingEntry> = {};\n for (const [transactionId, value] of Object.entries(restored)) {\n if (isPendingEntry(value)) pending[transactionId] = value;\n }\n return { ...currentState, pending };\n}\n\n/** Drops the oldest entries until the set fits, by announcement time. */\nfunction capPending(\n pending: Record<string, PendingEntry>,\n): Record<string, PendingEntry> {\n const entries = Object.entries(pending);\n if (entries.length <= MAX_PENDING) return pending;\n\n return Object.fromEntries(\n entries\n .sort(([, a], [, b]) => a.at.localeCompare(b.at))\n .slice(entries.length - MAX_PENDING),\n );\n}\n\nexport const usePendingState = create<PendingState>()(\n persist(\n (set, get) => ({\n pending: {},\n began: transaction =>\n set(state => ({\n pending: capPending({\n ...state.pending,\n [transaction.transactionId]: { ...transaction, state: 'pending' },\n }),\n })),\n // Reads current state rather than taking it from the caller, so a save\n // handler cannot ask about a store snapshot older than its own `await`.\n attach: (transactionId, record) => {\n const entry = get().pending[transactionId];\n if (entry === undefined) return false;\n set(state => ({\n pending: { ...state.pending, [transactionId]: { ...entry, record } },\n }));\n return true;\n },\n // Settling drops the entry rather than marking it done: the server's copy\n // is the truth now, and the list query that just refetched is showing it.\n settle: transactionId =>\n set(state => {\n if (!(transactionId in state.pending)) return state;\n const { [transactionId]: _settled, ...rest } = state.pending;\n return { pending: rest };\n }),\n // A failure is *kept*, marked, until someone reads it. Dropping it here\n // would un-render the optimistic row and say nothing about why.\n fail: (transactionId, reason) =>\n set(state => {\n const entry = state.pending[transactionId];\n if (entry === undefined) return state;\n return {\n pending: {\n ...state.pending,\n [transactionId]: { ...entry, state: 'failed', reason },\n },\n };\n }),\n dismiss: transactionId =>\n set(state => {\n if (!(transactionId in state.pending)) return state;\n const { [transactionId]: _dismissed, ...rest } = state.pending;\n return { pending: rest };\n }),\n }),\n {\n // Replaced with a scoped name before rehydrating, exactly as the tab and\n // draft stores are. This literal is only what an unscoped store would\n // write, and nothing rehydrates without passing through that call.\n name: 'pending',\n version: PENDING_VERSION,\n migrate: migratePending,\n merge: mergePending,\n storage: createJSONStorage(() =>\n makeIndexedDbStateStorage(WORKSPACE_DB, PENDING_STORE),\n ),\n partialize: persistedPending,\n skipHydration: true,\n },\n ),\n);\n"],"names":["create","createJSONStorage","persist","makeIndexedDbStateStorage","WORKSPACE_DB","persistedPending","store","pending","Object","entries","transactionId","record","_dropped","rest","PENDING_STORE","MAX_PENDING","PENDING_VERSION","emptyPending","migratePending","isPendingEntry","value","entry","entity","at","state","mergePending","persistedState","currentState","restored","capPending","length","fromEntries","sort","a","b","localeCompare","slice","usePendingState","set","get","began","transaction","attach","undefined","settle","_settled","fail","reason","dismiss","_dismissed","name","version","migrate","merge","storage","partialize","skipHydration"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../src/lib/workspace/pending-state.ts"],"sourcesContent":["'use client';\n\nimport type { PendingEntry, PendingTransaction } from '@entifix/transactions';\nimport { create } from 'zustand';\nimport { createJSONStorage, persist } from 'zustand/middleware';\n\nimport { makeIndexedDbStateStorage } from './idb-state-storage.js';\nimport { WORKSPACE_DB } from './tabs-state.js';\n\n/**\n * Writes the browser started and the server has not finished, keyed by\n * transaction id.\n *\n * ⚠️ **This is not workspace state, and the distinction is load-bearing.** Tabs\n * and drafts belong to the workspace and are scoped by `WorkspaceShell`; a\n * pending write is not, because a create happens at `/<basePath>/new` on the\n * plain route, outside any `WorkspaceShell` (a generated list still uses plain\n * `href`s — a known residual of the address grammar). A store mounted inside\n * the workspace would\n * be written to by nothing, or — worse — written to *before* `persist.setOptions`\n * had scoped it, which is the unscoped cross-account restore that keying a\n * draft per principal exists to prevent. So `PendingTransactionsProvider` sits at the authenticated layout\n * instead, above both.\n *\n * It shares the workspace's IndexedDB database because it is the same browser\n * profile and the same scope key, not because it is the same concern.\n */\nexport interface PendingState {\n pending: Record<string, PendingEntry>;\n began(transaction: PendingTransaction): void;\n attach(transactionId: string, record: unknown): boolean;\n settle(transactionId: string): void;\n fail(transactionId: string, reason?: string): void;\n dismiss(transactionId: string): void;\n}\n\n/**\n * ⚠️ **`record` is stripped before persisting.** It is a class instance, and a\n * draft-style JSON round trip does not preserve one — it returns as something\n * else, silently. So a refresh keeps the *watch* and loses the row,\n * which is the documented behaviour: past a refresh, server truth plus an honest\n * notice beats a record rebuilt from a blob.\n */\nexport function persistedPending(\n store: PendingState,\n): Pick<PendingState, 'pending'> {\n const pending: Record<string, PendingEntry> = {};\n for (const [transactionId, { record: _dropped, ...rest }] of Object.entries(\n store.pending,\n )) {\n pending[transactionId] = rest;\n }\n return { pending };\n}\n\nconst PENDING_STORE = 'stores';\n\n/**\n * How many pending writes are watched at once, oldest evicted first.\n *\n * The same reasoning as {@link MAX_TABS}: an unbounded set persisted to\n * IndexedDB grows without anything ever pruning it, and reconciliation asks the\n * tracker once per entry on every reconnect — so the cap is also what keeps that\n * fan-out a handful of requests rather than an unbounded one.\n *\n * An evicted entry is dropped silently. The record is still on the server and\n * the list's own query is the fallback; what is lost is only the watching.\n */\nexport const MAX_PENDING = 12;\n\n/**\n * Bump when the *shape* of an entry changes, and every persisted pending write\n * is discarded on the next load.\n *\n * Discarding is right here for a reason the other two stores do not share: an\n * entry is a claim that something is still in flight, and it stops being true on\n * its own. A stale one restored from a previous build watches a transaction that\n * settled hours ago, and the reconcile that would correct it is keyed on a shape\n * this build may no longer understand.\n */\nexport const PENDING_VERSION = 1;\n\nconst emptyPending: Pick<PendingState, 'pending'> = { pending: {} };\n\n/**\n * Discard, never guess — written out rather than left to zustand's default,\n * which discards too but logs an error for a decision that was made on purpose.\n * Same call as `migrateDrafts` and `migrateTabs`.\n */\nexport function migratePending(): Pick<PendingState, 'pending'> {\n return emptyPending;\n}\n\n/** Whether a restored value is an entry this build can act on. */\nfunction isPendingEntry(value: unknown): value is PendingEntry {\n if (value === null || typeof value !== 'object') return false;\n const entry = value as Partial<PendingEntry>;\n return (\n typeof entry.transactionId === 'string' &&\n typeof entry.entity === 'string' &&\n typeof entry.at === 'string' &&\n (entry.state === 'pending' || entry.state === 'failed')\n );\n}\n\n/**\n * Restores only the entries that are actually entries — per entry, so one bad\n * record does not take the rest with it. The `mergeDrafts` rule, for the same\n * reason: this is what happens when something else was written by a build that\n * predates the shape.\n */\nexport function mergePending(\n persistedState: unknown,\n currentState: PendingState,\n): PendingState {\n const restored = (persistedState as Partial<PendingState> | undefined)\n ?.pending;\n if (restored === null || typeof restored !== 'object') return currentState;\n\n const pending: Record<string, PendingEntry> = {};\n for (const [transactionId, value] of Object.entries(restored)) {\n if (isPendingEntry(value)) pending[transactionId] = value;\n }\n return { ...currentState, pending };\n}\n\n/** Drops the oldest entries until the set fits, by announcement time. */\nfunction capPending(\n pending: Record<string, PendingEntry>,\n): Record<string, PendingEntry> {\n const entries = Object.entries(pending);\n if (entries.length <= MAX_PENDING) return pending;\n\n return Object.fromEntries(\n entries\n .sort(([, a], [, b]) => a.at.localeCompare(b.at))\n .slice(entries.length - MAX_PENDING),\n );\n}\n\nexport const usePendingState = create<PendingState>()(\n persist(\n (set, get) => ({\n pending: {},\n began: transaction =>\n set(state => ({\n pending: capPending({\n ...state.pending,\n [transaction.transactionId]: { ...transaction, state: 'pending' },\n }),\n })),\n // Reads current state rather than taking it from the caller, so a save\n // handler cannot ask about a store snapshot older than its own `await`.\n attach: (transactionId, record) => {\n const entry = get().pending[transactionId];\n if (entry === undefined) return false;\n set(state => ({\n pending: { ...state.pending, [transactionId]: { ...entry, record } },\n }));\n return true;\n },\n // Settling drops the entry rather than marking it done: the server's copy\n // is the truth now, and the list query that just refetched is showing it.\n settle: transactionId =>\n set(state => {\n if (!(transactionId in state.pending)) return state;\n const { [transactionId]: _settled, ...rest } = state.pending;\n return { pending: rest };\n }),\n // A failure is *kept*, marked, until someone reads it. Dropping it here\n // would un-render the optimistic row and say nothing about why.\n fail: (transactionId, reason) =>\n set(state => {\n const entry = state.pending[transactionId];\n if (entry === undefined) return state;\n return {\n pending: {\n ...state.pending,\n [transactionId]: { ...entry, state: 'failed', reason },\n },\n };\n }),\n dismiss: transactionId =>\n set(state => {\n if (!(transactionId in state.pending)) return state;\n const { [transactionId]: _dismissed, ...rest } = state.pending;\n return { pending: rest };\n }),\n }),\n {\n // Replaced with a scoped name before rehydrating, exactly as the tab and\n // draft stores are. This literal is only what an unscoped store would\n // write, and nothing rehydrates without passing through that call.\n name: 'pending',\n version: PENDING_VERSION,\n migrate: migratePending,\n merge: mergePending,\n storage: createJSONStorage(() =>\n makeIndexedDbStateStorage(WORKSPACE_DB, PENDING_STORE),\n ),\n partialize: persistedPending,\n skipHydration: true,\n },\n ),\n);\n"],"names":["create","createJSONStorage","persist","makeIndexedDbStateStorage","WORKSPACE_DB","persistedPending","store","pending","Object","entries","transactionId","record","_dropped","rest","PENDING_STORE","MAX_PENDING","PENDING_VERSION","emptyPending","migratePending","isPendingEntry","value","entry","entity","at","state","mergePending","persistedState","currentState","restored","capPending","length","fromEntries","sort","a","b","localeCompare","slice","usePendingState","set","get","began","transaction","attach","undefined","settle","_settled","fail","reason","dismiss","_dismissed","name","version","migrate","merge","storage","partialize","skipHydration"],"mappings":"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"}
@@ -20,7 +20,7 @@ const WORKSPACE_STORE = 'stores';
20
20
  * whose `param` no longer resolves renders the workspace's dead-link fallback,
21
21
  * so a drifted snapshot is a strip of broken tabs rather than an error anyone
22
22
  * can act on.
23
- */ // 2: the tab address became `<screenType>:<key>[:<id>]` (ADR 0042), so every
23
+ */ // 2: the tab address became `<screenType>:<key>[:<id>]`, so every
24
24
  // `catalog:` / `entity:` / `system:` param a previous build persisted addresses
25
25
  // a kind that no longer exists.
26
26
  export const TABS_VERSION = 2;