@spooky-sync/cli 0.0.1-canary.323 → 0.0.1-canary.325

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.
package/AGENTS.md CHANGED
@@ -87,20 +87,21 @@ Two that come up when a deployment misbehaves:
87
87
 
88
88
  In your `.surql` source, comment annotations attached to `DEFINE FIELD` / `DEFINE TABLE` change codegen output:
89
89
 
90
- - `-- @crdt text` (above a `DEFINE FIELD`) — marks a field as a Loro CRDT text field. Consumers must use `useCrdtField` to read/write it; plain `useQuery` will see stale or unmerged content.
90
+ - `-- @crdt text` / `-- @crdt prosemirror` (above a `DEFINE FIELD ... TYPE string`), optionally with `-- @cursor`: a collaborative Loro field. `crdt_schema::expand` (run by every schema pipeline: server schema, events, internal schema, client schema) rewrites the field into a server-written plain-text mirror (`DEFAULT ''` plus a VALUE guard that keeps `$before` for any record-user write) and generates, per parent `P`: `P__crdt_log` (append-only edit log; create follows P's update rule and select P's select rule, both rebased through the `doc` link on the AST by `rebase_permission`; update/delete NONE; impersonation sessions may not create), `P__crdt_presence` (`@cursor` only), the `<field>__crdt` fold stamp on `P`, a cleanup event, and the shared `_00_crdt_doc` (canonical snapshots, SSP-only) and `_00_crdt_peer` (peer id ownership). It refuses a non-string type, a VALUE/ASSERT on the field, nested fields, relation tables, reserved names (`*__crdt_log`, `*__crdt_presence`, `_00_crdt_doc`, `*__crdt` fields) and the `surrealism` deploy mode. Clients edit through `useCrdtField`; the column itself is readable and filterable like any string. Design: `docs/crdt-v2.md`.
91
91
  - `-- @parent` (suffix on `DEFINE FIELD ... TYPE record<...>`) — marks the column as the parent side of a relationship; written automatically from the auth context, never by client code.
92
92
  - `-- @nosync` (above a `DEFINE TABLE`) — marks a table as server-only: it is omitted from generated types and relations (and any `record<...>` link pointing at it is dropped), no sync events are emitted for it, and the scheduler/SSP exclude it from snapshots and bootstrap. The table still lives in the main DB and is still backed up. The CLI bakes a `COMMENT 'sp00ky:nosync'` marker onto the server-side `DEFINE TABLE` so the runtime services detect it via `INFO FOR DB`. Distinct from `PERMISSIONS FOR select WHERE false`, which only locks reads — a permission-locked table is still synced.
93
93
  - `-- @nosync` (above a `DEFINE FIELD`) — marks a single field server-only: omitted from generated types and from the client's local cache schema, omitted from sync event payloads, and omitted from the scheduler replica and SSP bootstrap row scans. **Not a read barrier**: a client's down-sync `SELECT` still returns the column over the wire, and it is only discarded on arrival (`cleanRecord`). For real secrecy use `PERMISSIONS FOR select WHERE false` on the field, or move it to a `@nosync` table.
94
94
  - `-- @opaque` (above a `DEFINE FIELD`) — the field IS synced to the client (it stays in generated types and the local cache, flagged `opaque: true` on the column) but no server-side component stores the value. Intended for large blobs you render but never query on. Because nothing holds the value it cannot be evaluated: using it in `where`/`orderBy`/a join throws in the query builder and is rejected with a 400 at SSP registration, and a schema whose `PERMISSIONS` or `DEFINE INDEX` references one fails to build. Delivery works because sync payloads carry ids + versions, not field values — the client reads the row body straight from SurrealDB.
95
95
 
96
- All three field-level exclusions (`@nosync`, `@crdt`, `@opaque`) get `COMMENT 'sp00ky:opaque'` baked onto the server `DEFINE FIELD` (`schema_builder::add_opaque_field_markers`). The scheduler replica and the SSP bootstrap read that marker from `INFO FOR TABLE` and turn it into a `SELECT * OMIT ...` projection. Both halves are required: skipping a field from the ingest payload while the bootstrap still loads it makes the SSP circuit and the scheduler replica disagree about the row's key set permanently (the replica applies updates with `MERGE`, the circuit replaces the whole row), which shows up as an unfixable `spky verify` mismatch.
96
+ Both field-level exclusions (`@nosync`, `@opaque`) get `COMMENT 'sp00ky:opaque'` baked onto the server `DEFINE FIELD` (`schema_builder::add_opaque_field_markers`). The scheduler replica and the SSP bootstrap read that marker from `INFO FOR TABLE` and turn it into a `SELECT * OMIT ...` projection. Both halves are required: skipping a field from the ingest payload while the bootstrap still loads it makes the SSP circuit and the scheduler replica disagree about the row's key set permanently (the replica applies updates with `MERGE`, the circuit replaces the whole row), which shows up as an unfixable `spky verify` mismatch. A `@crdt` field is not excluded (its mirror is an ordinary synced column) unless it also carries `@opaque`; the generated log's `data` and presence `state` columns are `@opaque` with the marker baked in, because the internal-schema step re-applies those definitions without the marker pass.
97
97
 
98
98
  Example:
99
99
  ```sql
100
100
  DEFINE TABLE thread SCHEMAFULL ...;
101
101
 
102
- -- @crdt text
103
- DEFINE FIELD content ON TABLE thread TYPE string ASSERT $value != NONE;
102
+ -- @crdt prosemirror
103
+ -- @cursor
104
+ DEFINE FIELD content ON TABLE thread TYPE string;
104
105
 
105
106
  DEFINE FIELD author ON TABLE thread TYPE record<user>; -- @parent
106
107
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/cli",
3
- "version": "0.0.1-canary.323",
3
+ "version": "0.0.1-canary.325",
4
4
  "description": "Generate TypeScript/Dart types from SurrealDB schema files",
5
5
  "type": "module",
6
6
  "main": "./dist/syncgen.cjs",
@@ -62,10 +62,10 @@
62
62
  "vitest": "^1.0.0"
63
63
  },
64
64
  "optionalDependencies": {
65
- "@spooky-sync/cli-darwin-arm64": "0.0.1-canary.323",
66
- "@spooky-sync/cli-darwin-x64": "0.0.1-canary.323",
67
- "@spooky-sync/cli-linux-arm64": "0.0.1-canary.323",
68
- "@spooky-sync/cli-linux-x64": "0.0.1-canary.323",
69
- "@spooky-sync/cli-win32-x64": "0.0.1-canary.323"
65
+ "@spooky-sync/cli-darwin-arm64": "0.0.1-canary.325",
66
+ "@spooky-sync/cli-darwin-x64": "0.0.1-canary.325",
67
+ "@spooky-sync/cli-linux-arm64": "0.0.1-canary.325",
68
+ "@spooky-sync/cli-linux-x64": "0.0.1-canary.325",
69
+ "@spooky-sync/cli-win32-x64": "0.0.1-canary.325"
70
70
  }
71
71
  }
@@ -4,7 +4,7 @@ A short, scannable list of patterns an AI agent (or human) reaches for when writ
4
4
 
5
5
  Render any recipe with:
6
6
  ```
7
- spky scaffold <recipe> --table <your-table>
7
+ spky recipe <recipe> --table <your-table>
8
8
  ```
9
9
  or pass `--out path/to/file.tsx` to write the snippet directly.
10
10
 
@@ -12,18 +12,18 @@ or pass `--out path/to/file.tsx` to write the snippet directly.
12
12
 
13
13
  ### `live-list`
14
14
  **When to use:** you want a reactive, sorted list of rows from a table that updates as records change.
15
- **Render:** `spky scaffold live-list --table thread`
15
+ **Render:** `spky recipe live-list --table thread`
16
16
  **Gotcha:** end the query with `.build()` — `useQuery` will hang on a bare builder.
17
17
 
18
18
  ### `optimistic-mutation`
19
19
  **When to use:** you're inserting a new record from a UI action (form submit, button click) and want the local cache to update immediately while the mutation drains to the remote.
20
- **Render:** `spky scaffold optimistic-mutation --table thread`
20
+ **Render:** `spky recipe optimistic-mutation --table thread`
21
21
  **Gotcha:** `db.create` takes a *full* record-ID string (`'thread:abc'`), not `(table, payload)`. Use `Uuid` to mint IDs.
22
22
 
23
23
  ### `crdt-text-field`
24
- **When to use:** a text column is annotated `-- @crdt text` in your schema and you need a collaborative editor wired to it.
25
- **Render:** `spky scaffold crdt-text-field --table thread --field content`
26
- **Gotcha:** never read the field via `useQuery`; always `useCrdtField`. Writes must pass `{ debounced: true }` to `db.update` so rapid keystrokes coalesce.
24
+ **When to use:** a `TYPE string` field is annotated `-- @crdt text` in your schema and several people should edit it at once.
25
+ **Render:** `spky recipe crdt-text-field --table note --field body`
26
+ **Gotcha:** edit through `useCrdtField(table, () => id, field)` and its `getDoc()`; never put the field in a `db.create` / `db.update` payload (the server owns it). The column itself is the server-written plain text, fine to read with `useQuery` for previews, but it trails live edits until the server folds the edit log. For `@crdt prosemirror` fields bind a ProseMirror editor with `loro-prosemirror` instead of a textarea.
27
27
 
28
28
  ## Related
29
29
 
@@ -1,36 +1,54 @@
1
1
  // Recipe: crdt-text-field
2
- // Wire a CRDT text column (`{{table}}.{{field}}` annotated `-- @crdt text` in the schema)
3
- // to a textarea. Concurrent edits from multiple clients merge via Loro.
2
+ // A collaborative textarea for `{{table}}.{{field}}`, declared in the schema as
3
+ //
4
+ // -- @crdt text
5
+ // DEFINE FIELD {{field}} ON TABLE {{table}} TYPE string;
6
+ //
7
+ // The editor binds to the field's Loro document. Each client sends only its own
8
+ // edits (rows of the generated `{{table}}__crdt_log` table, through the outbox)
9
+ // and merges everyone else's as they sync; the server folds them into the
10
+ // canonical document. The `{{field}}` column itself is the server-written plain
11
+ // text: render it for previews, never write it with `db.create` / `db.update`.
4
12
 
5
- import { useDb, useQuery, useCrdtField } from '@spooky-sync/client-solid';
13
+ import { createEffect, createSignal, onCleanup, Show } from 'solid-js';
14
+ import { useCrdtField, useDb, useQuery } from '@spooky-sync/client-solid';
6
15
  import { schema } from '../schema.gen';
7
16
 
8
17
  export function {{TablePascal}}{{FieldPascal}}Editor(props: { id: string }) {
9
- const db = useDb<typeof schema>();
18
+ // 3 arguments: table, a reactive record id, field. Reopens when the id changes.
19
+ const field = useCrdtField('{{table}}', () => props.id, '{{field}}');
20
+ const [text, setText] = createSignal('');
10
21
 
11
- // Pull the surrounding row so we know the field is loaded.
12
- const rowResult = useQuery(() =>
13
- db.query('{{table}}').where({ id: props.id } as any).build()
14
- );
15
- const row = () => rowResult.data()?.[0];
16
-
17
- // Bind the CRDT field. All four args take accessor functions for SolidJS tracking.
18
- const field = useCrdtField(
19
- '{{table}}',
20
- () => row()?.id,
21
- '{{field}}',
22
- () => row()?.{{field}}
23
- );
22
+ // Follow the document: local and remote edits alike re-render the textarea.
23
+ createEffect(() => {
24
+ const doc = field()?.getDoc();
25
+ if (!doc) return;
26
+ const loroText = doc.getText('{{field}}');
27
+ setText(loroText.toString());
28
+ onCleanup(doc.subscribe(() => setText(loroText.toString())));
29
+ });
24
30
 
25
- const handleInput = async (e: InputEvent) => {
26
- const next = (e.currentTarget as HTMLTextAreaElement).value;
27
- const r = row();
28
- if (!r?.id) return;
29
- // `debounced` coalesces rapid keystrokes into a single mutation.
30
- await db.update('{{table}}', r.id, { {{field}}: next }, { debounced: true });
31
+ const handleInput = (e: InputEvent & { currentTarget: HTMLTextAreaElement }) => {
32
+ const doc = field()?.getDoc();
33
+ if (!doc) return;
34
+ // Diff into the document rather than replacing it, so concurrent edits merge.
35
+ doc.getText('{{field}}').update(e.currentTarget.value);
36
+ doc.commit();
31
37
  };
32
38
 
33
39
  return (
34
- <textarea value={field.value() ?? ''} onInput={handleInput} />
40
+ <Show when={field()} fallback={<p>Loading...</p>}>
41
+ <textarea value={text()} onInput={handleInput} />
42
+ </Show>
43
+ );
44
+ }
45
+
46
+ // Previews read the column like any other. It is the document's text as of the
47
+ // server's last fold, so it trails live edits (about a minute by default).
48
+ export function {{TablePascal}}{{FieldPascal}}Preview(props: { id: string }) {
49
+ const db = useDb<typeof schema>();
50
+ const result = useQuery(() =>
51
+ db.query('{{table}}').where({ id: props.id } as any).build()
35
52
  );
53
+ return <p>{result.data()?.[0]?.{{field}}}</p>;
36
54
  }