@abide/abide 0.102.0 → 0.104.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 (73) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/bin/abide.ts +31 -28
  3. package/docs/REGISTRY.md +40 -36
  4. package/docs/RULEBOOK.md +113 -92
  5. package/docs/abide.md +788 -554
  6. package/package.json +2 -1
  7. package/scaffold/AGENTS.md +13 -6
  8. package/src/compiler/TypeSession.ts +108 -35
  9. package/src/compiler/appMap.ts +91 -0
  10. package/src/compiler/argsSchemaOf.ts +1 -1
  11. package/src/compiler/checkApp.ts +35 -18
  12. package/src/compiler/checkedShadow.ts +40 -7
  13. package/src/compiler/compileApp.ts +271 -76
  14. package/src/compiler/compiledEntry.ts +10 -6
  15. package/src/compiler/componentGraph.ts +6 -7
  16. package/src/compiler/declaredExports.ts +3 -3
  17. package/src/compiler/emitClient.ts +3 -3
  18. package/src/compiler/emitComponent.ts +9 -9
  19. package/src/compiler/emitSource.ts +5 -9
  20. package/src/compiler/handlersUnder.ts +114 -73
  21. package/src/compiler/hooksCalled.ts +35 -0
  22. package/src/compiler/mountsOf.ts +30 -18
  23. package/src/compiler/parseAbide.ts +12 -12
  24. package/src/compiler/projectShadow.ts +71 -46
  25. package/src/compiler/readsIn.ts +2 -2
  26. package/src/compiler/scaffold.ts +24 -21
  27. package/src/compiler/serveLanguage.ts +0 -24
  28. package/src/compiler/sharedModuleWrites.ts +2 -2
  29. package/src/compiler/splitImports.ts +4 -4
  30. package/src/compiler/writeDefaultProject.ts +28 -1
  31. package/src/server/MODULE_QUERIES.ts +12 -3
  32. package/src/server/abideModules.ts +156 -53
  33. package/src/server/answerRequest.ts +9 -4
  34. package/src/server/answerRpc.ts +18 -16
  35. package/src/server/appTree.ts +92 -25
  36. package/src/server/commands.ts +5 -4
  37. package/src/server/componentModules.d.ts +1 -2
  38. package/src/server/createApp.ts +23 -11
  39. package/src/server/devWorker.ts +5 -1
  40. package/src/server/fenceServer.ts +36 -13
  41. package/src/server/generatedSurfaces.ts +12 -53
  42. package/src/server/hooks.ts +17 -4
  43. package/src/server/index.ts +1 -0
  44. package/src/server/installSourceMaps.ts +6 -1
  45. package/src/server/launcherOptions.ts +12 -13
  46. package/src/server/launcherPage.ts +2 -2
  47. package/src/server/nativeMenu.ts +3 -3
  48. package/src/server/preload.ts +13 -0
  49. package/src/server/runDesktop.ts +11 -16
  50. package/src/server/serveDev.ts +8 -2
  51. package/src/server/surfaceOf.ts +2 -0
  52. package/src/server/versionOf.ts +1 -2
  53. package/src/server/withAbideSources.ts +1 -1
  54. package/src/shared/ADDRESSES.ts +3 -0
  55. package/src/shared/LAUNCHER_FILES.ts +9 -0
  56. package/src/shared/RESERVED_DIRECTORIES.ts +19 -4
  57. package/src/shared/index.ts +2 -1
  58. package/src/{ui → shared}/menuBridge.ts +1 -1
  59. package/src/shared/menuItem.ts +2 -2
  60. package/src/shared/onMenu.ts +58 -0
  61. package/src/shared/reactive/Cursor.ts +66 -123
  62. package/src/shared/reactive/Reactive.ts +74 -28
  63. package/src/shared/reactive/callLoad.ts +4 -18
  64. package/src/shared/reactive/chunksOfStream.ts +10 -34
  65. package/src/{ui → shared}/reportMenu.ts +7 -9
  66. package/src/shared/routePattern.ts +74 -23
  67. package/src/shared/rpc.ts +1 -1
  68. package/src/ui/Router.ts +0 -6
  69. package/src/compiler/syncTypes.ts +0 -32
  70. package/src/compiler/writeTypes.ts +0 -82
  71. package/src/server/LAUNCHER_FILES.ts +0 -9
  72. /package/scaffold/src/{page.abide → index.abide} +0 -0
  73. /package/src/{compiler → shared}/SPECIFIERS.ts +0 -0
@@ -1,12 +1,11 @@
1
- // 38.80 — THE `launcher` A `src/launcher.abide`'S `<script module>` EXPORTS: the window the desktop
2
- // app opens and the forms its launcher offers. 34.32 — read off the module as the app launches,
3
- // never by evaluating it during the bundle.
1
+ // 38.80 — WHAT THE `onLauncher` A `src/launcher.abide` CALLS ANSWERS: the window the desktop app opens
2
+ // and the forms its launcher offers, read as the app launches.
4
3
 
5
4
  import { isRecord } from '#shared/isRecord.ts'
6
5
 
7
6
  /**
8
- * What the `launcher` export may set. 38.81 — turning off both forms is not one of them, so the
9
- * checker refuses it where the export is typed.
7
+ * What `onLauncher` may answer. 38.81 — turning off both forms is not one of them, so the checker
8
+ * refuses it where the answer is typed.
10
9
  */
11
10
  export type LauncherOptions = {
12
11
  width?: number
@@ -29,10 +28,10 @@ const SIZES = ['width', 'height', 'minWidth', 'minHeight'] as const
29
28
  const FORMS = ['start', 'connect'] as const
30
29
 
31
30
  /**
32
- * The launcher `exported` sets, or the refusal naming what is wrong with it — the export read at
33
- * run time, where an untyped one can still name a key it does not take.
31
+ * The launcher what `onLauncher` answered sets, or the refusal naming what is wrong with it — read
32
+ * at run time, where an untyped answer can still name a key it does not take.
34
33
  */
35
- export function launcherOf(exported: unknown): Launcher | string {
34
+ export function launcherOf(answered: unknown): Launcher | string {
36
35
  const launcher: Launcher = {
37
36
  width: 1100,
38
37
  height: 760,
@@ -41,9 +40,9 @@ export function launcherOf(exported: unknown): Launcher | string {
41
40
  start: true,
42
41
  connect: true,
43
42
  }
44
- if (exported === undefined) return launcher
45
- if (!isRecord(exported)) return '`launcher` is an object'
46
- for (const [key, value] of Object.entries(exported)) {
43
+ if (answered === undefined) return launcher
44
+ if (!isRecord(answered)) return '`onLauncher` answers an object'
45
+ for (const [key, value] of Object.entries(answered)) {
47
46
  if (
48
47
  (SIZES as readonly string[]).includes(key) &&
49
48
  typeof value === 'number' &&
@@ -56,10 +55,10 @@ export function launcherOf(exported: unknown): Launcher | string {
56
55
  )
57
56
  launcher[key as (typeof FORMS)[number]] = value
58
57
  else
59
- return `\`launcher.${key}\` is not a launcher option, or ${JSON.stringify(value)} is not a value it takes`
58
+ return `\`${key}\` is not a launcher option, or ${JSON.stringify(value)} is not a value it takes`
60
59
  }
61
60
  // 38.81.
62
61
  if (!launcher.start && !launcher.connect)
63
- return '`launcher` turns off both start and connect, which leaves the launcher nothing to do'
62
+ return '`onLauncher` turns off both start and connect, which leaves the launcher nothing to do'
64
63
  return launcher
65
64
  }
@@ -21,9 +21,9 @@ const SYSTEM_ACCENT = '#0a84ff'
21
21
  export type Launch = {
22
22
  /** The app's name, as its package gives it. */
23
23
  name: string
24
- /** Where the start form posts, or `undefined` where the `launcher` export turns starting off. */
24
+ /** Where the start form posts, or `undefined` where what `onLauncher` answers turns starting off. */
25
25
  start: string | undefined
26
- /** Where the connect form posts, or `undefined` where the `launcher` export turns connecting off. */
26
+ /** Where the connect form posts, or `undefined` where what `onLauncher` answers turns connecting off. */
27
27
  connect: string | undefined
28
28
  fields: ConfigField[]
29
29
  /** The URL the last connect was given. */
@@ -1,5 +1,5 @@
1
- // THE DESKTOP APP'S MENU BAR (38.88): the default, with what the page the window shows exports,
2
- // built as an `NSMenu`.
1
+ // THE DESKTOP APP'S MENU BAR (38.88): the default, with the menus the standing layouts and page file
2
+ // with `onMenu`, built as an `NSMenu`.
3
3
 
4
4
  import { FFIType } from 'bun:ffi'
5
5
  import type { MenuRole } from '#shared/menuItem.ts'
@@ -77,7 +77,7 @@ const NAMED_KEYS: Record<string, string> = {
77
77
  * The menu bar the window shows: the application, File, Edit, View and Window menus, and `added`.
78
78
  *
79
79
  * 38.88 — AN ADDED MENU NAMED AS A DEFAULT ONE GROWS IT, and any other stands before Window, where
80
- * the platform puts an app's own. 38.83 — no Connect… where the `launcher` export turns connecting off.
80
+ * the platform puts an app's own. 38.83 — no Connect… where what `onLauncher` answers turns connecting off.
81
81
  */
82
82
  export function menuBar(
83
83
  appName: string,
@@ -0,0 +1,13 @@
1
+ // 38.109 — `@abide/abide/test`, PRELOADED BY `bun test`: the app's compilation served before any test
2
+ // file is loaded, so a test imports a `.abide` as any module — beside the code it tests, at the top
3
+ // of the file, with no `createApp` run first. Bun loads every import of a file before it evaluates
4
+ // any, so registering the compilation from inside a test is always too late; a preload is the
5
+ // one place that runs first. The build is `createApp`'s own under `bun test` (42.16), so a test that
6
+ // boots the app afterwards reuses it rather than building twice.
7
+
8
+ import { appRoot, servedBuild } from './createApp.ts'
9
+
10
+ const root = appRoot(undefined)
11
+ // A run from a directory that holds no app — a workspace's root — preloads nothing.
12
+ // Fresh whatever `NODE_ENV` says: `bun test` sets it only where nothing else has.
13
+ if (root !== undefined) await servedBuild(root, true)
@@ -26,7 +26,7 @@ import { APP_PACKAGE, configuredMount, dataDirectory } from './config.ts'
26
26
  import { configFields } from './configFields.ts'
27
27
  import { type App, type AppOrigin, bootApp } from './createApp.ts'
28
28
  import { executableName } from './executableName.ts'
29
- import { reportUnexpected } from './hooks.ts'
29
+ import { HOOKS, inOrder, reportUnexpected } from './hooks.ts'
30
30
  import { installServer } from './installServer.ts'
31
31
  import { launcherOf } from './launcherOptions.ts'
32
32
  import { type Launch, launcherPage } from './launcherPage.ts'
@@ -53,15 +53,10 @@ type Failure = {
53
53
 
54
54
  /** What `abide bundle` read of the launcher's files, carried in the executable. */
55
55
  export type Desktop = {
56
- /** 38.80 — `src/launcher.abide`, where the app has one: its `launcher`, and its page. */
56
+ /** 38.82 — `src/launcher.abide`'s page, where the app has one that paints. */
57
57
  launch:
58
- | (() => Promise<{
59
- default: (props: Launch) => Component
60
- launcher?: unknown
61
- }>)
58
+ | (() => Promise<{ default: (props: Launch) => Component }>)
62
59
  | undefined
63
- /** 38.82 — whether that file has markup of its own, which then stands in for the built-in page. */
64
- custom: boolean
65
60
  /** The document the launch page renders into, its stylesheet already in its head (38.84). */
66
61
  document: string
67
62
  /** 38.101 — the app's icon as a data URL, and the accent taken from it. */
@@ -87,23 +82,23 @@ export async function runDesktop(
87
82
  async function launch(
88
83
  origin: AppOrigin,
89
84
  load: () => ReturnType<typeof builtSources>,
90
- { launch: launchModule, custom, document, icon, accent }: Desktop,
85
+ { launch: launchModule, document, icon, accent }: Desktop,
91
86
  ): Promise<void> {
92
87
  installServer()
93
88
  APP_PACKAGE.current = origin.package
94
- // 37.1 — imported for what it files, so the start form reads `onConfig`.
89
+ // 37.1 — imported for what it files, so the start form reads `onConfig` and the launcher's
90
+ // `onLauncher` is filed.
95
91
  await origin.hooks?.()
96
- // 34.32 — the `launcher` export, read off its module as the app launches. 38.85 — one that fails
97
- // to load leaves the built-in page, with its defaults.
98
- const launcherModule = await launchModule?.().catch((thrown: unknown) => {
92
+ // 38.85 — a launcher page that fails to load leaves the built-in page, with its defaults.
93
+ const launchPage = await launchModule?.().catch((thrown: unknown) => {
99
94
  reportUnexpected(thrown)
100
95
  return undefined
101
96
  })
102
- const options = launcherOf(launcherModule?.launcher)
103
- // 38.81 — a `launcher` the checker would have refused, written where nothing checked it.
97
+ // 38.80 — what its `onLauncher` answers.
98
+ const options = launcherOf(inOrder(HOOKS.launcher).at(-1)?.())
99
+ // 38.81 — an `onLauncher` answer the checker would have refused, written where nothing checked it.
104
100
  if (typeof options === 'string')
105
101
  throw new Error(`abide: ${options}. RULEBOOK 38.81.`)
106
- const launchPage = custom ? launcherModule : undefined
107
102
  const appName = executableName(origin.package.name)
108
103
  const directory = dataDirectory(origin.package.name ?? 'app')
109
104
  const stateFile = `${directory}/desktop.json`
@@ -22,6 +22,7 @@ import { watch } from 'node:fs' // bun exposes no file watcher of its own
22
22
  import { dirname, resolve } from 'node:path' // bun exposes no path helpers of its own
23
23
  import {
24
24
  type AppChecker,
25
+ compilationOf,
25
26
  compileApp,
26
27
  openChecker,
27
28
  } from '#compiler/compileApp.ts'
@@ -144,7 +145,7 @@ export async function serveDev(
144
145
  console.warn(formatDiagnosis(warning, process.cwd()))
145
146
  watchDependencies(compiled.files)
146
147
  return {
147
- compilation: { files: compiled.files, tables: compiled.tables },
148
+ compilation: compilationOf(compiled),
148
149
  published: compiled.published,
149
150
  failure: '',
150
151
  }
@@ -154,7 +155,12 @@ export async function serveDev(
154
155
  thrown instanceof Error ? thrown.message : String(thrown)
155
156
  console.error(`abide dev: ${failure}`)
156
157
  return {
157
- compilation: { files: {}, tables: {} },
158
+ compilation: {
159
+ files: {},
160
+ handlers: {},
161
+ tables: {},
162
+ mounted: {},
163
+ },
158
164
  published: new Map(),
159
165
  failure,
160
166
  }
@@ -31,6 +31,8 @@ export function surfaceOf(
31
31
  mount: Mount | Mounted,
32
32
  surface: Surface,
33
33
  ): Listed | undefined {
34
+ // 16.71 — a private handler is its file's alone.
35
+ if (mount.private) return undefined
34
36
  const live = 'handler' in mount ? mount : undefined
35
37
  // 16.37 — what its `clients` says, and the build's default where it says nothing.
36
38
  const carried =
@@ -1,8 +1,7 @@
1
1
  // 18.7 — THE VERSION A VALUE'S STANDING PRODUCTION IS NAMED BY OVER THE WIRE. Stamped where it is
2
2
  // asked for rather than where a production is minted, so a value no browser follows pays nothing on
3
3
  // its production path. A production is told apart from the one before by the ordinal, what is held
4
- // (6.10 moves no ordinal for `undefined`) and the patch epoch of what is held (5.7 moves no ordinal
5
- // for a patch).
4
+ // and the patch epoch of what is held (5.7 moves no ordinal for a patch).
6
5
  //
7
6
  // ONE COUNTER FOR THE PROCESS, started at the boot time in microseconds, so no two productions share
8
7
  // a number: not two values, not an entry evicted and built again, and not this process and the one
@@ -96,7 +96,7 @@ export async function withAbideSources(
96
96
  }
97
97
 
98
98
  /**
99
- * 16.28, 34.20 — the `.abide`'s text with every `<script server>` body blanked: the map is served
99
+ * 16.28, 34.19 — the `.abide`'s text with every `<script server>` body blanked: the map is served
100
100
  * to a browser, which reads none of one. Each line is kept, so every line the map places is still
101
101
  * the line it names.
102
102
  */
@@ -24,6 +24,9 @@ export const ABIDE_ADDRESS = '/__abide'
24
24
  */
25
25
  export const CLIENT_ADDRESS = `${ABIDE_ADDRESS}/client`
26
26
 
27
+ /** Where a private handler is answered, below its file's path and then its name (16.70). */
28
+ export const PRIVATE_HANDLERS_ADDRESS = `${ABIDE_ADDRESS}/handlers`
29
+
27
30
  // 20.5, 20.9, 36.7 — the generated surfaces, which the CLI reaches as well as the server answers.
28
31
 
29
32
  /** The generated OpenAPI document. */
@@ -0,0 +1,9 @@
1
+ // THE DESKTOP LAUNCHER'S FILES. Its page, which calls `onLauncher`, sits at the app's root beside the
2
+ // root layout, a name the framework finds rather than a page (23.21); the icon is a public file,
3
+ // so it is wherever the address it answers at says (36.11).
4
+
5
+ /** The `.abide` the desktop launcher renders, at the root alone. 38.80, 38.82. */
6
+ export const LAUNCHER_PAGE = 'launcher.abide'
7
+
8
+ /** The addresses of the desktop app's icon, the `.icns` first. 38.79. */
9
+ export const ICON_ADDRESSES = { icns: '/icon.icns', png: '/icon.png' } as const
@@ -1,6 +1,7 @@
1
- // THE TWO DIRECTORY NAMES THAT ARE NEVER AN ADDRESS (23.15): `server` fences a graph (16.29) and
2
- // `public` is answered as authored (36.11). Every other directory under the root is a segment of the
3
- // address of the `page.abide`, `layout.abide` and `error.abide` in it (23.14).
1
+ // THE FENCES: the four directory names that are never an address (23.15). `server` fences a graph
2
+ // (16.29), `public` is answered as authored (36.11), and `lib` and `tests` hold what is neither an
3
+ // address nor either of those. Every other directory under the root is a segment of the routes of
4
+ // the files directly in it (23.14), and holds nothing else (23.22).
4
5
 
5
6
  /** A module under one only the server loads. */
6
7
  export const SERVER_DIRECTORY = 'server'
@@ -8,8 +9,22 @@ export const SERVER_DIRECTORY = 'server'
8
9
  /** A file under one is answered as authored, at the path above it. */
9
10
  export const PUBLIC_DIRECTORY = 'public'
10
11
 
11
- /** Both, as the walk of the app tests a directory against them. */
12
+ /** The app's own code that is no address: components, modules, both sides. */
13
+ export const LIB_DIRECTORY = 'lib'
14
+
15
+ /** The app's tests. */
16
+ export const TESTS_DIRECTORY = 'tests'
17
+
18
+ /** All four, as the walk of the app tests a directory against them. */
12
19
  export const RESERVED_DIRECTORIES: ReadonlySet<string> = new Set([
13
20
  SERVER_DIRECTORY,
14
21
  PUBLIC_DIRECTORY,
22
+ LIB_DIRECTORY,
23
+ TESTS_DIRECTORY,
24
+ ])
25
+
26
+ /** 23.16 — the fences a `server` directory nests in: it fences a graph rather than an address. */
27
+ export const HOLDS_SERVER: ReadonlySet<string> = new Set([
28
+ LIB_DIRECTORY,
29
+ TESTS_DIRECTORY,
15
30
  ])
@@ -36,13 +36,14 @@ export { listOf } from './listOf.ts'
36
36
  export { type Logger, type LogRecord, log } from './log.ts'
37
37
  export type { Middleware } from './Middleware.ts'
38
38
  export type { Params } from './matchRoute.ts'
39
- // 38.88 — a desktop app's menu items, exported by the route's layouts and page.
39
+ // 38.88 — a desktop app's menu items, and the hook a route's layouts and page file them with.
40
40
  export {
41
41
  type MenuEntry,
42
42
  type MenuItem,
43
43
  type MenuRole,
44
44
  menuItem,
45
45
  } from './menuItem.ts'
46
+ export { onMenu } from './onMenu.ts'
46
47
  // `navigate` ALONE, of everything routing needs. This barrel is what an app resolves through the
47
48
  // bare `@abide/abide` specifier, and the matcher, the pattern grammar and `routeOf` are reached by path
48
49
  // from the two places that have them — the build and the client router. Putting them here would
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The channel a desktop app's window opens to the page for its menu, or `undefined` in a browser.
3
3
  *
4
- * ITS OWN MODULE, so the router asks without importing the reporter: `reportMenu` is a chunk only a
4
+ * ITS OWN MODULE, so `onMenu` asks without importing the reporter: `reportMenu` is a chunk only a
5
5
  * page inside a desktop app loads. 38.88.
6
6
  */
7
7
  export function menuBridge():
@@ -1,5 +1,5 @@
1
- // 38.88 — THE DESKTOP APP'S MENU BAR IS THE ROUTE'S: a layout or a page exports `menu` from its
2
- // `<script module>`, and the window shows what the route it stands on exports. An item made by
1
+ // 38.88 — THE DESKTOP APP'S MENU BAR IS THE ROUTE'S: a layout or a page calls `onMenu` in its
2
+ // `<script>`, and the window shows the menus filed while it stands. An item made by
3
3
  // `menuItem` is one a page answers; the rest are an address or a role, which need no page.
4
4
 
5
5
  /** What a menu item does natively, where an address or a page's own answer could not. 38.89. */
@@ -0,0 +1,58 @@
1
+ // 38.88, 38.92 — THE MENUS A LAYOUT OR A PAGE ADDS TO A DESKTOP APP'S MENU BAR, filed while it
2
+ // stands. Called in its `<script>`, so the menus are a function of its state: what `read` reads moving
3
+ // tells the window again, and the component going takes them with it. Outside a desktop app's window
4
+ // there is no menu bar, and nothing is filed.
5
+
6
+ import { menuBridge } from './menuBridge.ts'
7
+ import type { MenuItem } from './menuItem.ts'
8
+ import { watch } from './reactive/index.ts'
9
+
10
+ /** One `onMenu`'s menus, as it last answered, and whether its component has gone. */
11
+ type Filed = { menus: MenuItem[]; gone: boolean }
12
+
13
+ // IN THE ORDER FILED, which is outermost first: a layout's setup runs before the page inside it.
14
+ let filed: Filed[] = []
15
+ let telling = false
16
+
17
+ /** Files the menus `read` answers for as long as the calling component stands; returns what unfiles them. */
18
+ export function onMenu(read: () => readonly MenuItem[]): () => void {
19
+ if (menuBridge() === undefined) return () => {}
20
+ const entry: Filed = { menus: [], gone: false }
21
+ filed.push(entry)
22
+ const stop = watch(() => {
23
+ entry.gone = false
24
+ entry.menus = [...read()]
25
+ tell()
26
+ // A rerun files again at once; a component that went stays gone.
27
+ return () => {
28
+ entry.gone = true
29
+ tell()
30
+ }
31
+ })
32
+ // 12.8 — a `read` that threw stopped the watch with no cleanup to run, so the unfiling is
33
+ // done here too.
34
+ return () => {
35
+ stop()
36
+ entry.gone = true
37
+ tell()
38
+ }
39
+ }
40
+
41
+ // ONCE PER TURN, after every rerun and every component a navigation took has settled.
42
+ function tell(): void {
43
+ if (telling) return
44
+ telling = true
45
+ queueMicrotask(async () => {
46
+ telling = false
47
+ const standing: Filed[] = []
48
+ const menus: MenuItem[] = []
49
+ for (const entry of filed) {
50
+ if (entry.gone) continue
51
+ standing.push(entry)
52
+ menus.push(...entry.menus)
53
+ }
54
+ filed = standing
55
+ const { reportMenu } = await import('./reportMenu.ts')
56
+ reportMenu(menus)
57
+ })
58
+ }
@@ -1,14 +1,12 @@
1
- // THE CURSOR. RULEBOOK 2.5: `for await (… of s)` — the production in flight, then each one after
2
- // it. On a streaming producer that is one turn per CHUNK, read from the chunk buffer by ordinal,
3
- // so two chunks between two `await`s are two turns of the loop. On every other value it is the
4
- // standing production: a burst between two turns collapses to the latest, since nothing else is
5
- // held. An app that wants every message keeps them — a channel is an array.
1
+ // THE CURSOR. RULEBOOK 2.6: a streaming producer's chunks, read from the chunk buffer by ordinal,
2
+ // so two chunks between two `await`s are two turns of the loop, and a patch or a write over the
3
+ // stream moves no place (5.7, 9.12). Where the run ENDS is the caller's settling, handed to
4
+ // `closeOn`: `for await` over a `Reactive` is `Reactive.ts`'s `producedRun`.
6
5
  //
7
6
  // A NARROW FACE rather than the `Cell`, the same way `tracking.ts` takes a `Tracked`: what a
8
7
  // cursor reads is a handful of fields, and naming them keeps the cell's internals out of a
9
8
  // second file.
10
9
 
11
- import { PATCHES } from './PATCHES.ts'
12
10
  import {
13
11
  dropReader,
14
12
  newSubscriber,
@@ -23,21 +21,14 @@ import {
23
21
  * place in a stream's chunk buffer without the buffer having to hold one.
24
22
  */
25
23
  export type Retained = Tracked & {
26
- /** The window a `throttle` or a `debounce` holds open, `holding` while a production waits on it. */
27
- cap: { holding: boolean } | null
28
24
  ordinal: number
29
25
  ring: unknown[]
30
26
  ringBase: number
31
- servable: boolean
32
- state: number
33
- /**
34
- * Non-null on a streaming producer, whose ring is the accumulation of the run being served
35
- * (9.1) and is retained whole — the chunks ARE the value.
36
- */
37
- stream: object | null
38
- value: unknown
39
27
  }
40
28
 
29
+ /** What `take` answers when the cursor has caught up — never a chunk, which may be `undefined`. */
30
+ export const CAUGHT_UP: unique symbol = Symbol('caught up')
31
+
41
32
  // The cursors standing over each source, for a patch to keep the place of the ones behind (5.7).
42
33
  const CURSORS = new WeakMap<Retained, Set<Cursor>>()
43
34
 
@@ -70,61 +61,36 @@ export function hasCursorBehind(source: Retained): boolean {
70
61
  }
71
62
 
72
63
  /**
73
- * A live reader of a source's productions, one turn per production, in order.
64
+ * A reader of a stream's chunks, in order, waking whoever waits on it when one lands.
74
65
  *
75
- * A RECORD, NOT A SCOPE. The four closures this was — `take`, `notify`, `stop` and `next` — shared
76
- * one environment holding `at`, `waiting`, `source` and `reader`, and a cursor OUTLIVES the call
77
- * that made it: a `for await` holds one for the life of the loop, so the environment and every
78
- * function object hanging off it is live that whole time. Fields on a prototype are the object
79
- * alone, and `at` — read and written on every turn — becomes a property load on a fixed structure
80
- * rather than a scope load.
66
+ * A RECORD, NOT A SCOPE. A cursor outlives the call that made it — a `for await` holds one for the
67
+ * life of the loop — so fields on a prototype are the object alone, and `at`, read and written on
68
+ * every turn, is a property load on a fixed structure rather than a scope load.
81
69
  */
82
- export class Cursor implements AsyncIterator<unknown> {
70
+ export class Cursor {
83
71
  source: Retained
84
- /** The ordinal of the last production this cursor handed over. */
72
+ /** The ordinal of the last chunk this cursor handed over. */
85
73
  at: number
86
- /**
87
- * The patch epoch the value it last handed over stood at.
88
- *
89
- * A PATCH MINTS NO ORDINAL (5.7) — the follow's version tells a patch from a production by
90
- * exactly that — so a cursor over a value patched in place reads the change off the object.
91
- */
92
- patched: number
93
- /**
94
- * The turns asked for and not yet answered.
95
- *
96
- * A QUEUE rather than one slot because a consumer may ask twice before either answer arrives
97
- * — `Promise.all([ it.next(), it.next() ])` is the shape, and with one slot the second `next`
98
- * overwrote the first's resolver and that promise never settled at all. `for await` asks one
99
- * at a time, which is why nothing here said so.
100
- */
101
- waiting: ((result: IteratorResult<unknown>) => void)[]
74
+ /** Called once at the next wake, then cleared. */
75
+ wake: (() => void) | null
76
+ /** Whether the run `closeOn` was handed has settled. */
77
+ closed: boolean
78
+ /** What that run rejected with. */
79
+ failure: unknown
102
80
  reader: Subscriber
103
81
 
104
82
  constructor(source: Retained, from: number) {
105
83
  this.source = source
106
84
  this.at = from
107
- this.patched = 0
108
- this.waiting = []
109
- // The one closure left, and it captures this cursor and nothing else. `notify` is
110
- // called as a method on the SUBSCRIBER, so it cannot be the cursor's own method —
111
- // and widening `Subscriber` with a back-edge would put a field on the shape every
112
- // reader in the graph carries for the sake of the few that are cursors.
113
- // Registered outside the tracking protocol, so the source it waits on is declared
114
- // rather than discovered — see the same shape in `Reactive`'s `settled`.
85
+ this.wake = null
86
+ this.closed = false
87
+ this.failure = undefined
88
+ // The one closure left, and it captures this cursor and nothing else. `notify` is called as a
89
+ // method on the SUBSCRIBER, so it cannot be the cursor's own method. Registered outside the
90
+ // tracking protocol, so the source it waits on is declared rather than discovered — see the
91
+ // same shape in `Reactive`'s `settled`.
115
92
  this.reader = newSubscriber([source], null)
116
- // One production per waiting turn, in the order they were asked for: a burst that
117
- // lands three productions on a consumer holding two turns answers both.
118
- this.reader.notify = (): void => {
119
- const waiting = this.waiting
120
- while (waiting.length !== 0) {
121
- const next = this.take()
122
- if (next === null) return
123
- ;(waiting.shift() as (result: IteratorResult<unknown>) => void)(
124
- next,
125
- )
126
- }
127
- }
93
+ this.reader.notify = (): void => this.woken()
128
94
  source.readers.push(this.reader)
129
95
  let cursors = CURSORS.get(source)
130
96
  if (cursors === undefined) {
@@ -135,83 +101,60 @@ export class Cursor implements AsyncIterator<unknown> {
135
101
  }
136
102
 
137
103
  /**
138
- * One production, or `null` where the cursor has caught up.
104
+ * The next chunk, or `CAUGHT_UP`.
139
105
  *
140
- * Reading a stream's chunks by ordinal is what keeps a burst from being a gap or a repeat.
106
+ * Reading by ordinal is what keeps a burst from being a gap or a repeat.
141
107
  */
142
- take(): IteratorResult<unknown> | null {
108
+ take(): unknown {
143
109
  const source = this.source
144
- let at = this.at
145
- if (source.stream === null) {
146
- const value = source.value
147
- // 6.10: an `undefined` standing is caught up, not a turn.
148
- if (value === undefined) return null
149
- const patched =
150
- value !== null && typeof value === 'object'
151
- ? (PATCHES.written.get(value) ?? 0)
152
- : 0
153
- if (at >= source.ordinal && patched === this.patched) return null
154
- // 5.9 — A PRODUCTION A WINDOW HOLDS has minted its ordinal and is not yet the value: the
155
- // turn is the release's, which wakes this cursor.
156
- if (source.cap?.holding === true) return null
157
- // The standing production is the only one held, so everything minted or patched
158
- // since the last turn collapses into it.
159
- this.at = source.ordinal
160
- this.patched = patched
161
- return { value, done: false }
162
- }
163
- if (at >= source.ordinal) return null
164
- // A run that restarted moved the buffer past this cursor: answer from its start.
165
- if (at < source.ringBase) at = source.ringBase
166
- const value = source.ring[at - source.ringBase]
167
- this.at = at + 1
168
- return { value, done: false }
169
- }
170
-
171
- next(): Promise<IteratorResult<unknown>> {
172
- if (!this.reader.alive)
173
- return Promise.resolve({ value: undefined, done: true })
174
- // A TURN ALREADY ASKED FOR AND NOT ANSWERED KEEPS THE ORDER: taking from the
175
- // ring here while one is queued would hand the later caller the earlier
176
- // production.
177
- if (this.waiting.length === 0) {
178
- const ready = this.take()
179
- // NOT `async`, and no `await` on the caught-up arm. A cursor behind a
180
- // stream's chunks is the common shape while a burst drains, and an
181
- // `async` here would put a microtask tick between every one of those
182
- // turns.
183
- if (ready !== null) return Promise.resolve(ready)
184
- }
185
- const waiting = this.waiting
186
- return new Promise((resolve) => {
187
- waiting.push(resolve)
188
- })
110
+ if (this.at >= source.ordinal) return CAUGHT_UP
111
+ const value = source.ring[this.at - source.ringBase]
112
+ this.at += 1
113
+ return value
189
114
  }
190
115
 
191
116
  /**
192
- * Ends the cursor: `for await` calls this on `break`, on `return` and on a throw.
117
+ * Ends the cursor's run where `run` settles: `closed` is set, `failure` holds a rejection, and
118
+ * the current `turn` wakes.
193
119
  *
194
- * It is the only thing that drops the subscriber — a live cursor has no end of its own.
120
+ * ONE REACTION ON THE CLOSE FOR THE WHOLE RUN, never one per chunk held until the run ends. A
121
+ * chunk landing in the same turn as the close is still unread when the close wakes the caller,
122
+ * so a drain before reading `closed` hands it over rather than losing it to whichever settled
123
+ * first.
195
124
  */
196
- return(): Promise<IteratorResult<unknown>> {
197
- return this.stop()
125
+ closeOn(run: PromiseLike<unknown>): void {
126
+ run.then(
127
+ () => {
128
+ this.closed = true
129
+ this.woken()
130
+ },
131
+ (error: unknown) => {
132
+ this.closed = true
133
+ this.failure = error
134
+ this.woken()
135
+ },
136
+ )
137
+ }
138
+
139
+ /** Settles at the next chunk or the close, whichever lands first. */
140
+ turn(): Promise<void> {
141
+ return new Promise((resolve) => {
142
+ this.wake = resolve
143
+ })
198
144
  }
199
145
 
200
- throw(): Promise<IteratorResult<unknown>> {
201
- return this.stop()
146
+ private woken(): void {
147
+ const wake = this.wake
148
+ this.wake = null
149
+ wake?.()
202
150
  }
203
151
 
204
- stop(): Promise<IteratorResult<unknown>> {
152
+ /** Drops the subscriber; nothing else does. */
153
+ stop(): void {
205
154
  const reader = this.reader
206
155
  reader.alive = false
207
156
  dropReader(this.source, reader)
208
157
  CURSORS.get(this.source)?.delete(this)
209
- const waiting = this.waiting
210
- while (waiting.length !== 0)
211
- (waiting.shift() as (result: IteratorResult<unknown>) => void)({
212
- value: undefined,
213
- done: true,
214
- })
215
- return Promise.resolve({ value: undefined, done: true })
158
+ this.wake = null
216
159
  }
217
160
  }