toiljs 0.0.106 → 0.0.108

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "toiljs",
3
3
  "type": "module",
4
- "version": "0.0.106",
4
+ "version": "0.0.108",
5
5
  "author": "Dacely",
6
6
  "description": "The modern React framework: a file-based React frontend and a ToilScript-compiled WebAssembly backend.",
7
7
  "repository": {
@@ -136,7 +136,7 @@
136
136
  "nodemailer": "^9.0.3",
137
137
  "picocolors": "^1.1.1",
138
138
  "sharp": "^0.35.3",
139
- "toilscript": "^0.1.54",
139
+ "toilscript": "^0.1.56",
140
140
  "typescript-eslint": "^8.62.1",
141
141
  "vite": "^8.1.3",
142
142
  "vite-imagetools": "^10.0.1",
@@ -147,7 +147,7 @@
147
147
  "prettier": ">=3.0.0",
148
148
  "react": ">=18.0.0",
149
149
  "react-dom": ">=18.0.0",
150
- "toilscript": ">=0.1.54",
150
+ "toilscript": ">=0.1.56",
151
151
  "typescript": ">=6.0.0"
152
152
  },
153
153
  "overrides": {
@@ -488,6 +488,9 @@ class AuthAccount {
488
488
  // TWOFA_NONE (2FA off), the safe default, so existing accounts stay logged in
489
489
  // by password alone until they opt in via /auth/2fa/setup.
490
490
  twoFactorMethod: u8 = 0;
491
+ // Append-only: the STABLE ToilUserId, derived once at registration. Reset rotates
492
+ // publicKey but leaves this, so the id is stable across a reset.
493
+ toilUserId: Uint8Array = new Uint8Array(0);
491
494
  }
492
495
 
493
496
  @data
@@ -609,16 +612,6 @@ class Auth {
609
612
  // on domain A is a DIFFERENT account + index entry from the same on domain B.
610
613
  const h = realm(ctx);
611
614
 
612
- // Distinguishable statuses (not the generic 401) so the UI can say
613
- // "username taken" / "email in use". Signup intentionally leaks existence
614
- // (a product choice, now gated behind the PoP above); reset never does.
615
- if (AuthDb.accounts.exists(new Username(h, username))) {
616
- return Response.bytes(new DataWriter().writeU8(ST_TAKEN).toBytes());
617
- }
618
- if (AuthDb.emails.exists(new EmailKey(h, email))) {
619
- return Response.bytes(new DataWriter().writeU8(ST_EMAIL_TAKEN).toBytes());
620
- }
621
-
622
615
  // Send the confirm email + store unconfirmed when EITHER flag is on
623
616
  // (AUTH_EMAIL_CONFIRMATION or the stricter AUTH_REQUIRE_EMAIL_CONFIRMATION).
624
617
  // Whether login is then BLOCKED is a separate check (requireConfirmation,
@@ -633,14 +626,20 @@ class Auth {
633
626
  a.memKiB = MEM_KIB;
634
627
  a.iterations = ITERS;
635
628
  a.parallelism = PAR;
636
- // create-if-absent: a racing duplicate registration loses here, not above.
629
+ // Derive + persist the stable id once. Tenant-scoped, and unique WITHOUT a
630
+ // separate index: the framed derive maps distinct (username, domain) to
631
+ // distinct ids, and username is unique per realm (accounts.create below), so
632
+ // two accounts can never share an id.
633
+ a.toilUserId = ToilUserId.derive(pk, username, authDomain(ctx)).toBytes();
634
+ // create-if-absent IS the authoritative, race-safe uniqueness guard
635
+ // (serialized at the key's home): a duplicate username loses here and gets the
636
+ // distinguishable ST_TAKEN. No pre-`exists` read needed -- the create result
637
+ // is the answer.
637
638
  if (!AuthDb.accounts.create(new Username(h, username), a)) {
638
639
  return Response.bytes(new DataWriter().writeU8(ST_TAKEN).toBytes());
639
640
  }
640
- // Reserve the email -> username index (uniqueness for reset-by-email, scoped
641
- // per host). A racing duplicate email loses here: roll back the account we
642
- // just created so a lost email race can't orphan a username whose email index
643
- // points at someone else (which would also break reset-by-email for it).
641
+ // Reserve the email -> username index (email uniqueness + reset-by-email). A
642
+ // duplicate email loses here; roll back the account so nothing is orphaned.
644
643
  if (!AuthDb.emails.create(new EmailKey(h, email), new EmailOwner(username))) {
645
644
  AuthDb.accounts.delete(new Username(h, username));
646
645
  return Response.bytes(new DataWriter().writeU8(ST_EMAIL_TAKEN).toBytes());
@@ -841,8 +840,7 @@ class Auth {
841
840
  // extended one). `__toilEncodeAuthUser` is injected by `--authUser`: it constructs the `@user`
842
841
  // (app fields at their defaults), sets the reserved identity, and encodes it. The stable
843
842
  // ToilUserId is derived from the login public key + username + tenant domain.
844
- const domain = authDomain(ctx);
845
- const toilUserId = ToilUserId.derive(acct.publicKey, ch.username, domain).toBytes();
843
+ const toilUserId = this.stableUserId(ctx, h, ch.username, acct);
846
844
  const userData = __toilEncodeAuthUser(toilUserId, ch.username);
847
845
  const w = new DataWriter();
848
846
  w.writeU8(ST_OK);
@@ -958,6 +956,16 @@ class Auth {
958
956
  return Response.bytes(new DataWriter().writeU8(ST_OK).toBytes());
959
957
  }
960
958
 
959
+ /** The account's STABLE ToilUserId, persisted at registration (so reset never
960
+ * changes it). An empty stored id is derived once, persisted, and indexed. */
961
+ private stableUserId(ctx: RouteContext, h: string, username: string, acct: AuthAccount): Uint8Array {
962
+ if (acct.toilUserId.length > 0) return acct.toilUserId;
963
+ const uid = ToilUserId.derive(acct.publicKey, username, authDomain(ctx)).toBytes();
964
+ acct.toilUserId = uid;
965
+ AuthDb.accounts.patch(new Username(h, username), acct);
966
+ return uid;
967
+ }
968
+
961
969
  /** GET /auth/me (@auth: 401 without a valid session) -> the typed user
962
970
  * (bytes(toilUserId) str(username)). `AuthService.getUser()` is auto-typed
963
971
  * to the built-in `@user` with no type argument. */
@@ -1037,8 +1045,7 @@ class Auth {
1037
1045
  AuthDb.twoFaLogins.getDelete(key);
1038
1046
  const acct = AuthDb.accounts.get(new Username(h, ch.username));
1039
1047
  if (acct == null) return fail();
1040
- const domain = authDomain(ctx);
1041
- const toilUserId = ToilUserId.derive(acct.publicKey, ch.username, domain).toBytes();
1048
+ const toilUserId = this.stableUserId(ctx, h, ch.username, acct);
1042
1049
  const userData = __toilEncodeAuthUser(toilUserId, ch.username);
1043
1050
  const w = new DataWriter();
1044
1051
  w.writeU8(ST_OK);
@@ -228,8 +228,16 @@ function __labelled(label: string, transcriptHash: Uint8Array): Uint8Array {
228
228
  function __reqIsSecure(): bool {
229
229
  const req = Server.currentRequest;
230
230
  if (req == null) return false;
231
+ // Proxied deployments: trust the terminating proxy's scheme header.
231
232
  const proto = req.header('x-forwarded-proto');
232
- return proto != null && proto == 'https';
233
+ if (proto != null) return proto == 'https';
234
+ // Direct-origin deployments: the edge terminates TLS itself and sends no
235
+ // x-forwarded-proto, so fall back to the configured public origin. An https
236
+ // PUBLIC_BASE_URL means the site is served over TLS, so the auth cookies must
237
+ // be Secure + __Host-/__Secure- prefixed. Without this a direct-origin HTTPS
238
+ // edge sets bare cookies and the client's getUser() misses the __Secure- name.
239
+ const base = Environment.get('PUBLIC_BASE_URL');
240
+ return base != null && base.startsWith('https://');
233
241
  }
234
242
 
235
243
  export namespace AuthService {
@@ -402,7 +410,10 @@ export namespace AuthService {
402
410
  let cookie = Cookie.create(USER_BASE, base64UrlEncode(userData))
403
411
  .sameSite(SameSite.Lax)
404
412
  .maxAge(<i64>ttlSecs);
405
- cookie = secure ? cookie.asSecurePrefixed() : cookie.path('/');
413
+ // Path=/ in BOTH branches: asSecurePrefixed() sets no path, so without this
414
+ // the cookie is scoped to the /auth request path and getUser() can't read it
415
+ // on /login or /dashboard.
416
+ cookie = secure ? cookie.asSecurePrefixed().path('/') : cookie.path('/');
406
417
  return cookie;
407
418
  }
408
419
 
@@ -412,7 +423,10 @@ export namespace AuthService {
412
423
  let cookie = Cookie.create(USER_BASE, '')
413
424
  .sameSite(SameSite.Lax)
414
425
  .maxAge(0);
415
- cookie = secure ? cookie.asSecurePrefixed() : cookie.path('/');
426
+ // Path=/ in BOTH branches: asSecurePrefixed() sets no path, so without this
427
+ // the cookie is scoped to the /auth request path and getUser() can't read it
428
+ // on /login or /dashboard.
429
+ cookie = secure ? cookie.asSecurePrefixed().path('/') : cookie.path('/');
416
430
  return cookie;
417
431
  }
418
432
 
@@ -30,12 +30,18 @@ export class ToilUserId {
30
30
  * unambiguous, and `domain` is the trusted tail supplied by the host, not the user.
31
31
  */
32
32
  static derive(mldsaPublicKey: Uint8Array, identifier: string, domain: string): ToilUserId {
33
+ // Length-framed, domain-separated preimage: a u32 length prefix on every
34
+ // field keeps ids collision-free across tenants; the context tag separates
35
+ // this hash from other sha256 uses.
36
+ const ctx = Uint8Array.wrap(String.UTF8.encode(USERID_CONTEXT));
33
37
  const id = Uint8Array.wrap(String.UTF8.encode(identifier));
34
38
  const dom = Uint8Array.wrap(String.UTF8.encode(domain));
35
- const buf = new Uint8Array(mldsaPublicKey.length + id.length + dom.length);
36
- buf.set(mldsaPublicKey, 0);
37
- buf.set(id, mldsaPublicKey.length);
38
- buf.set(dom, mldsaPublicKey.length + id.length);
39
+ const buf = new Uint8Array(16 + ctx.length + mldsaPublicKey.length + id.length + dom.length);
40
+ let off = 0;
41
+ off = putField(buf, off, ctx);
42
+ off = putField(buf, off, mldsaPublicKey);
43
+ off = putField(buf, off, id);
44
+ off = putField(buf, off, dom);
39
45
  return ToilUserId.fromBytes(crypto.sha256(buf));
40
46
  }
41
47
 
@@ -105,3 +111,18 @@ export class ToilUserId {
105
111
  return !this.equals(other);
106
112
  }
107
113
  }
114
+
115
+ /** Domain-separation tag for {@link ToilUserId.derive}; versioned. */
116
+ const USERID_CONTEXT: string = 'toil.userid.v1';
117
+
118
+ /** Append a u32-LE length prefix + `src` to `buf` at `off`; return the new offset. */
119
+ function putField(buf: Uint8Array, off: i32, src: Uint8Array): i32 {
120
+ const n = src.length;
121
+ buf[off] = <u8>(n & 0xff);
122
+ buf[off + 1] = <u8>((n >> 8) & 0xff);
123
+ buf[off + 2] = <u8>((n >> 16) & 0xff);
124
+ buf[off + 3] = <u8>((n >> 24) & 0xff);
125
+ off += 4;
126
+ buf.set(src, off);
127
+ return off + n;
128
+ }
@@ -23,7 +23,6 @@
23
23
 
24
24
  import fs from 'node:fs';
25
25
  import path from 'node:path';
26
- import { fileURLToPath } from 'node:url';
27
26
 
28
27
  import pc from 'picocolors';
29
28
  import { createServer } from 'vite';
@@ -33,20 +32,19 @@ import { RESERVED_AUTH_EMAIL_NAMES, renderEmailFile, toPascal, type RenderedEmai
33
32
  import { createViteConfig } from './vite.js';
34
33
 
35
34
  /**
36
- * The toiljs package's `server/globals` LIB directory: the ambient, no-import
37
- * surface (`EmailService`, `AuthService`, …) the toilconfig `lib` array points
38
- * at. The auth-email module is generated HERE, not in the app's `server/` dir,
39
- * because that is what makes `AuthEmail` resolvable from the node_modules-compiled
40
- * `AuthController` with NO import (an exported namespace in a plain app entry is
41
- * module-scoped, not global). Prefers the app's install so a symlinked/linked
42
- * toiljs is followed; falls back to the running package (hoisted install).
35
+ * The APP-LOCAL library directory the auth-email module is generated into,
36
+ * relative to the project root (posix, for the toilscript `--lib` CLI arg). It is
37
+ * passed to the compiler via `--lib` (see runToilscriptPass), which "uses exports
38
+ * of all top-level files at this path as globals". That is what makes the
39
+ * generated `AuthEmail` namespace resolvable from the node_modules-compiled
40
+ * `AuthController` with NO import, the same way `EmailService`/`AuthService` are
41
+ * ambient. Generating HERE (the app's own gitignored `.toil/`) rather than into
42
+ * `node_modules/toiljs/server/globals` keeps it PER-APP: a pnpm/workspace/linked
43
+ * install physically shares that package dir, so writing there would let one app
44
+ * clobber another's templates (or delete the file another app is compiling), and
45
+ * a read-only store would fail the build. `.toil` is always app-writable.
43
46
  */
44
- function globalsDir(root: string): string {
45
- const inApp = path.join(root, 'node_modules', 'toiljs', 'server', 'globals');
46
- if (fs.existsSync(inApp)) return inApp;
47
- const pkgDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
48
- return path.join(pkgDir, 'server', 'globals');
49
- }
47
+ export const AUTH_EMAILS_LIB_SUBDIR = '.toil/authlib';
50
48
 
51
49
  /** The reserved override names (PascalCase), one per built-in auth email. */
52
50
  type AuthName = 'AuthConfirm' | 'AuthReset' | 'Auth2fa';
@@ -204,17 +202,36 @@ function moduleSource(parts: Parts): string {
204
202
  }
205
203
 
206
204
  /**
207
- * (Re)write `_auth_emails.ts` in the toiljs lib globals dir so the built-in auth
208
- * controller has its email senders, baking any reserved `emails/*.tsx` override
209
- * over the default. Runs BEFORE the toilscript server build (like
210
- * {@link renderEmails}) so the module is compiled in. A no-op that removes a stale
211
- * module when `authOn` is false. Only spins up Vite when an override actually
212
- * exists.
205
+ * Best-effort removal of a stale copy from the pre-0.0.107 location
206
+ * (`node_modules/toiljs/server/globals/_auth_emails.ts`). Leaving it there would
207
+ * define a SECOND `AuthEmail` namespace alongside the new app-local one and break
208
+ * the compile with a duplicate symbol, so a project upgraded in place (without a
209
+ * fresh `npm i`) is cleaned up here. Never throws (a read-only store just skips).
210
+ */
211
+ function removeLegacyModule(root: string): void {
212
+ try {
213
+ const legacy = path.join(root, 'node_modules', 'toiljs', 'server', 'globals', GENERATED_BASENAME);
214
+ if (fs.existsSync(legacy)) fs.rmSync(legacy);
215
+ } catch {
216
+ // read-only / missing: nothing to clean, not fatal
217
+ }
218
+ }
219
+
220
+ /**
221
+ * (Re)write `_auth_emails.ts` into the app-local `.toil/authlib` LIB dir so the
222
+ * built-in auth controller has its email senders, baking any reserved
223
+ * `emails/*.tsx` override over the default. Runs BEFORE the toilscript server
224
+ * build (like {@link renderEmails}) so the module is compiled in via `--lib`. A
225
+ * no-op that removes a stale module when auth is off or there is no server. Only
226
+ * spins up Vite when an override actually exists.
213
227
  */
214
228
  export async function renderAuthEmails(cfg: ResolvedToilConfig, authOn: boolean): Promise<void> {
215
- const generatedPath = path.join(globalsDir(cfg.root), GENERATED_BASENAME);
229
+ const generatedPath = path.join(cfg.root, AUTH_EMAILS_LIB_SUBDIR, GENERATED_BASENAME);
230
+ removeLegacyModule(cfg.root);
216
231
 
217
- if (!authOn) {
232
+ // No server to compile into, or auth off: ensure no stale module lingers.
233
+ const hasServer = fs.existsSync(path.join(cfg.root, 'toilconfig.json'));
234
+ if (!authOn || !hasServer) {
218
235
  if (fs.existsSync(generatedPath)) fs.rmSync(generatedPath);
219
236
  return;
220
237
  }
@@ -325,7 +325,7 @@ function warn(msg: string): void {
325
325
  * there is no `emails/` directory. Removes a stale generated module when the
326
326
  * directory becomes empty.
327
327
  */
328
- export async function renderEmails(cfg: ResolvedToilConfig): Promise<void> {
328
+ export async function renderEmails(cfg: ResolvedToilConfig, authOn = false): Promise<void> {
329
329
  const emailsDir = path.join(cfg.root, 'emails');
330
330
  const generatedPath = path.join(serverDir(cfg.root), GENERATED_BASENAME);
331
331
 
@@ -355,7 +355,12 @@ export async function renderEmails(cfg: ResolvedToilConfig): Promise<void> {
355
355
  for (const file of files) {
356
356
  // Reserved auth-override templates (emails/auth-confirm.tsx, …) are
357
357
  // consumed by the auth pipeline, not surfaced as generic `Emails.*`.
358
- if (RESERVED_AUTH_EMAIL_NAMES.has(toPascal(path.basename(file).replace(/\.(tsx|jsx)$/, ''))))
358
+ // Only reserved WHEN AUTH IS ON: a non-auth project keeping an email
359
+ // by that name still gets its normal `Emails.<Name>.send(...)`.
360
+ if (
361
+ authOn &&
362
+ RESERVED_AUTH_EMAIL_NAMES.has(toPascal(path.basename(file).replace(/\.(tsx|jsx)$/, '')))
363
+ )
359
364
  continue;
360
365
  try {
361
366
  const r = await renderEmailFile(
@@ -14,7 +14,11 @@ import type { RunningBackend } from 'toiljs/backend';
14
14
 
15
15
  import { loadConfig, type ResolvedToilConfig } from './config.js';
16
16
  import { renderEmails } from './emails.js';
17
- import { AUTH_EMAILS_GENERATED_BASENAME, renderAuthEmails } from './auth-emails.js';
17
+ import {
18
+ AUTH_EMAILS_GENERATED_BASENAME,
19
+ AUTH_EMAILS_LIB_SUBDIR,
20
+ renderAuthEmails,
21
+ } from './auth-emails.js';
18
22
  import { generate, TOIL_SERVER_ENV_DTS } from './generate.js';
19
23
  import { prerenderStaticParams } from './ssg.js';
20
24
  import {
@@ -546,6 +550,11 @@ function runToilscriptPass(
546
550
  if (opts.rpcSurfaceFiles)
547
551
  for (const surfaceFile of opts.rpcSurfaceFiles) args.push('--rpcSurfaceFiles', surfaceFile);
548
552
  if (opts.authUser) args.push('--authUser');
553
+ // Built-in auth: add the app-local `.toil/authlib` as a library path so the
554
+ // generated `AuthEmail` (see renderAuthEmails) is an ambient global the
555
+ // node_modules-compiled AuthController resolves with no import. `--lib` merges
556
+ // with the toilconfig `lib` (it does not replace it). Written before this pass.
557
+ if (opts.authUser) args.push('--lib', AUTH_EMAILS_LIB_SUBDIR);
549
558
  // Each pass is handed its OWN entry subset (the per-tier `files`); suppress the toilconfig
550
559
  // `entries` so toilscript does not ALSO append every project entry to every pass (which would
551
560
  // pull, e.g., a `@stream` class into the cold daemon pass). serverEntryFiles already folds
@@ -595,8 +604,9 @@ function watchServer(cfg: ResolvedToilConfig, watcher: ViteDevServer['watcher'])
595
604
  process.stdout.write(pc.dim(' server changed, rebuilding…') + '\n');
596
605
  // Recompile emails/*.tsx -> the generated module before the server build,
597
606
  // so editing an email template hot-reloads like any other server change.
598
- renderEmails(cfg)
599
- .then(() => renderAuthEmails(cfg, serverAuthEnabled(root, cfg.auth)))
607
+ const authOn = serverAuthEnabled(root, cfg.auth);
608
+ renderEmails(cfg, authOn)
609
+ .then(() => renderAuthEmails(cfg, authOn))
600
610
  .then(() => buildServer(root, cfg.auth))
601
611
  .then(() => process.stdout.write(pc.green(' ✓ ') + pc.dim('server rebuilt') + '\n'))
602
612
  .catch((e: unknown) =>
@@ -879,10 +889,11 @@ export async function dev(opts: ToilCommandOptions = {}): Promise<ViteDevServer>
879
889
  const hasServer = fs.existsSync(path.join(cfg.root, 'toilconfig.json'));
880
890
  if (hasServer) process.stdout.write(pc.dim(' building the server (toilscript)…') + '\n');
881
891
  // Compile emails/*.tsx -> generated server module BEFORE toilscript builds it in.
882
- await renderEmails(cfg);
883
- // Built-in auth: (re)generate `<server>/_auth_emails.ts` (defaults + any emails/auth-*.tsx
884
- // overrides) so the injected AuthController's `AuthEmail.*` senders compile in.
885
- await renderAuthEmails(cfg, serverAuthEnabled(cfg.root, cfg.auth));
892
+ const authOnDev = serverAuthEnabled(cfg.root, cfg.auth);
893
+ await renderEmails(cfg, authOnDev);
894
+ // Built-in auth: (re)generate `.toil/authlib/_auth_emails.ts` (defaults + any emails/auth-*.tsx
895
+ // overrides) so the injected AuthController's `AuthEmail.*` senders compile in via --lib.
896
+ await renderAuthEmails(cfg, authOnDev);
886
897
  // Generate the client codegen first so the SSR slots pre-pass can load the route graph, then
887
898
  // emit the server-importable `<server>/_ssr/<name>.slots.ts` BEFORE the server build so its
888
899
  // `render` can import them. Dev reuses the prior build's shell (or the template) for the HASH;
@@ -1007,10 +1018,11 @@ export async function build(opts: ToilCommandOptions = {}): Promise<void> {
1007
1018
  if (hasServer && !opts.serverOnly)
1008
1019
  process.stdout.write(pc.dim(' building the server (toilscript)…') + '\n');
1009
1020
  // Compile emails/*.tsx -> generated server module BEFORE toilscript builds it in.
1010
- await renderEmails(cfg);
1011
- // Built-in auth: (re)generate `<server>/_auth_emails.ts` (defaults + any emails/auth-*.tsx
1012
- // overrides) so the injected AuthController's `AuthEmail.*` senders compile in.
1013
- await renderAuthEmails(cfg, serverAuthEnabled(cfg.root, cfg.auth));
1021
+ const authOnBuild = serverAuthEnabled(cfg.root, cfg.auth);
1022
+ await renderEmails(cfg, authOnBuild);
1023
+ // Built-in auth: (re)generate `.toil/authlib/_auth_emails.ts` (defaults + any emails/auth-*.tsx
1024
+ // overrides) so the injected AuthController's `AuthEmail.*` senders compile in via --lib.
1025
+ await renderAuthEmails(cfg, authOnBuild);
1014
1026
  // Generate the client codegen (`.toil/globals.ts`, `.toil/index.html`, …) NOW — before the
1015
1027
  // server build — so the SSR slots pre-pass below can load the route/layout module graph and
1016
1028
  // render the opted-in routes.
@@ -21,6 +21,7 @@ import {
21
21
  type DbCatalogState,
22
22
  DEFAULT_FILL_WAIT_MS,
23
23
  type DevCollectionHandle,
24
+ type DevField,
24
25
  isCollectionFamily,
25
26
  MAX_FILL_WAIT_MS,
26
27
  } from './types.js';
@@ -72,10 +73,13 @@ export function parseCatalog(wasm: Buffer): DbCatalogState {
72
73
  return { kind: 'malformed' };
73
74
  const fillAllowStale = fillAllowStaleByte === 1;
74
75
  const nFields = r.readU16();
76
+ const fields: DevField[] = [];
75
77
  for (let f = 0; f < nFields; f++) {
76
- r.readString(); // field name
77
- r.readString(); // field type
78
- r.readU8(); // isArray
78
+ const fName = r.readString();
79
+ const fType = r.readString();
80
+ const isArray = r.readU8() !== 0;
81
+ const unique = r.readU8() !== 0;
82
+ fields.push({ name: fName, typeName: fType, isArray, unique });
79
83
  }
80
84
  const nMig = r.readU16();
81
85
  for (let m = 0; m < nMig; m++) r.readU32(); // migratableFrom versions
@@ -96,6 +100,7 @@ export function parseCatalog(wasm: Buffer): DbCatalogState {
96
100
  placement,
97
101
  fillMaxWaitMs,
98
102
  fillAllowStale,
103
+ fields,
99
104
  });
100
105
  }
101
106
  }
@@ -685,6 +685,70 @@ export class DevDatabase {
685
685
  return this.store.has(storeKey(coll.name, readKey(ref, keyPtr, keyLen))) ? 1 : 0;
686
686
  }
687
687
 
688
+ /** Each `@unique` field's raw value in an encoded record, per the collection's
689
+ * field layout. `[]` when there is no layout or no `@unique` field. The
690
+ * compiler restricts `@unique` to top-level scalar/blob records, so the walker
691
+ * only skips fixed scalars and length-prefixed blobs. */
692
+ private uniqueValuesOf(coll: DevCollectionHandle, value: Buffer): { index: number; val: Buffer }[] {
693
+ const fields = coll.fields;
694
+ if (!fields || !fields.some((f) => f.unique)) return [];
695
+ const r = new DataReader(value);
696
+ const out: { index: number; val: Buffer }[] = [];
697
+ for (let i = 0; i < fields.length; i++) {
698
+ const f = fields[i];
699
+ if (f.isArray) break;
700
+ if (f.typeName === 'string') {
701
+ const s = r.readString();
702
+ if (f.unique) out.push({ index: i, val: Buffer.from(s, 'utf8') });
703
+ } else if (f.typeName === 'Uint8Array') {
704
+ const b = r.readBytes();
705
+ if (f.unique) out.push({ index: i, val: Buffer.from(b) });
706
+ } else {
707
+ switch (f.typeName) {
708
+ case 'bool':
709
+ case 'u8':
710
+ case 'i8':
711
+ r.readU8();
712
+ break;
713
+ case 'u16':
714
+ case 'i16':
715
+ r.readU16();
716
+ break;
717
+ case 'u32':
718
+ case 'i32':
719
+ case 'f32':
720
+ r.readU32();
721
+ break;
722
+ case 'u64':
723
+ case 'i64':
724
+ case 'f64':
725
+ r.readU64();
726
+ break;
727
+ default:
728
+ return out; // nested @data — compiler forbids @unique on/after it
729
+ }
730
+ }
731
+ }
732
+ return out;
733
+ }
734
+
735
+ /** True when another record in `coll` already holds one of `value`'s `@unique`
736
+ * field values (per-tenant uniqueness). `selfSk` is the writing record's store
737
+ * key so it never conflicts with itself; empty values are unique-if-present. */
738
+ private uniqueConflict(coll: DevCollectionHandle, value: Buffer, selfSk: string): boolean {
739
+ const uvals = this.uniqueValuesOf(coll, value).filter((u) => u.val.length > 0);
740
+ if (uvals.length === 0) return false;
741
+ const prefix = coll.name + '\0';
742
+ for (const [otherSk, otherVal] of this.store) {
743
+ if (otherSk === selfSk || !otherSk.startsWith(prefix)) continue;
744
+ const otherUvals = this.uniqueValuesOf(coll, otherVal);
745
+ for (const u of uvals) {
746
+ if (otherUvals.some((o) => o.index === u.index && o.val.equals(u.val))) return true;
747
+ }
748
+ }
749
+ return false;
750
+ }
751
+
688
752
  create(
689
753
  ref: MemoryRef,
690
754
  db: DbDevState,
@@ -706,9 +770,12 @@ export class DevDatabase {
706
770
  if (!start.fresh)
707
771
  return start.outcome ? this.replayRecordOutcome(db, start.outcome) : start.status;
708
772
  const sk = storeKey(coll.name, key);
709
- const outcome: RecordOutcome = this.store.has(sk)
710
- ? { kind: 'already_exists' }
711
- : { kind: 'unit' };
773
+ // @unique: a value already held by another record blocks the create, exactly
774
+ // like the edge's op_create claim (surfaced as AlreadyExists -> create false).
775
+ const outcome: RecordOutcome =
776
+ this.store.has(sk) || this.uniqueConflict(coll, value, sk)
777
+ ? { kind: 'already_exists' }
778
+ : { kind: 'unit' };
712
779
  if (outcome.kind === 'unit') {
713
780
  this.store.set(sk, value);
714
781
  this.stampVersion(coll, sk); // stamp the value type's current schema version
@@ -740,7 +807,9 @@ export class DevDatabase {
740
807
  return start.outcome ? this.replayRecordOutcome(db, start.outcome) : start.status;
741
808
  const sk = storeKey(coll.name, key);
742
809
  const outcome: RecordOutcome = this.store.has(sk)
743
- ? { kind: 'value', value: v, schemaVersion: this.currentSchemaVersion(coll) }
810
+ ? this.uniqueConflict(coll, v, sk)
811
+ ? { kind: 'already_exists' } // patch would collide a @unique field
812
+ : { kind: 'value', value: v, schemaVersion: this.currentSchemaVersion(coll) }
744
813
  : { kind: 'not_found' };
745
814
  if (outcome.kind === 'value') {
746
815
  this.store.set(sk, v);
@@ -28,6 +28,13 @@ export function isCollectionFamily(value: number): value is CollectionFamily {
28
28
  return value >= CollectionFamily.Record && value <= CollectionFamily.Capacity;
29
29
  }
30
30
 
31
+ export interface DevField {
32
+ name: string;
33
+ typeName: string;
34
+ isArray: boolean;
35
+ unique: boolean;
36
+ }
37
+
31
38
  export interface DevCollectionHandle {
32
39
  name: string;
33
40
  family: CollectionFamily;
@@ -36,6 +43,9 @@ export interface DevCollectionHandle {
36
43
  placement: number;
37
44
  fillMaxWaitMs: number;
38
45
  fillAllowStale: boolean;
46
+ /** The value type's field layout (declaration order); present when the catalog
47
+ * carried one. Drives @unique enforcement. */
48
+ fields?: DevField[];
39
49
  }
40
50
 
41
51
  export type DbCatalogState =