smoodly 0.0.37 → 0.0.39

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 (46) hide show
  1. package/dist/admin/fixed-nodes.d.ts +1 -1
  2. package/dist/admin/fixed-nodes.js +26 -1
  3. package/dist/admin/index.d.ts +1 -0
  4. package/dist/admin/index.js +1 -0
  5. package/dist/admin/ops-impl.js +34 -9
  6. package/dist/admin/shell/SchemaNotice.js +1 -1
  7. package/dist/admin/supabase-auth.js +10 -4
  8. package/dist/cli/add-admin.d.ts +11 -0
  9. package/dist/cli/add-admin.js +25 -0
  10. package/dist/cli/main.d.ts +5 -1
  11. package/dist/cli/main.js +23 -3
  12. package/dist/cli/migrate.d.ts +1 -1
  13. package/dist/cli/migrate.js +6 -1
  14. package/dist/cli/space-io.d.ts +1 -7
  15. package/dist/cli/space-io.js +2 -16
  16. package/dist/client.js +3 -1
  17. package/dist/content/content-api.d.ts +84 -0
  18. package/dist/content/content-api.js +170 -0
  19. package/dist/content/node.d.ts +7 -0
  20. package/dist/content/node.js +10 -0
  21. package/dist/env.d.ts +6 -3
  22. package/dist/env.js +39 -15
  23. package/dist/index.d.ts +4 -3
  24. package/dist/index.js +1 -0
  25. package/dist/member-store.d.ts +12 -0
  26. package/dist/member-store.js +8 -0
  27. package/dist/model-key.d.ts +1 -0
  28. package/dist/model-key.js +36 -0
  29. package/dist/next/page-renderer.js +6 -5
  30. package/dist/next/queries.js +1 -1
  31. package/dist/schema-read.d.ts +7 -0
  32. package/dist/schema-read.js +15 -0
  33. package/dist/schema-version.d.ts +2 -0
  34. package/dist/schema-version.js +3 -1
  35. package/dist/schema.d.ts +10 -4
  36. package/dist/site.d.ts +8 -0
  37. package/dist/site.js +40 -2
  38. package/dist/supabase-entry-store.js +53 -37
  39. package/dist/supabase-member-store.d.ts +6 -0
  40. package/dist/supabase-member-store.js +20 -2
  41. package/dist/supabase-store.js +16 -14
  42. package/dist/tags.d.ts +3 -0
  43. package/dist/tags.js +16 -0
  44. package/dist/version.d.ts +1 -0
  45. package/dist/version.js +2 -0
  46. package/package.json +16 -13
package/dist/site.js CHANGED
@@ -6,9 +6,11 @@ import { globalNodeId, SETTINGS_KEY, STYLES_KEY, CSS_KEY } from "./global.js";
6
6
  import { SMD_KEY } from "./section-wrapper.js";
7
7
  import { resolveStyleValues } from "./styles.js";
8
8
  import { resolveCustom } from "./custom/index.js";
9
- import { addressMap, codeAddresses } from "./addresses.js";
9
+ import { addressMap, codeAddresses, lastPart } from "./addresses.js";
10
10
  import { fullPath, servedAnchor } from "./anchors.js";
11
11
  import { withDefaults } from "./field-defaults.js";
12
+ import { modelKey } from "./model-key.js";
13
+ import { SMOODLY_VERSION } from "./version.js";
12
14
  import { toEntryItem, toPageItem } from "./read-shapes.js";
13
15
  import { entrySeo, pageSeo, SEO_KEY } from "./seo.js";
14
16
  /** The `page` prop for a loaded page. `href` is the app's seam from a
@@ -36,11 +38,22 @@ export function createSmoodlySite(options) {
36
38
  const custom = resolveCustom(options.custom);
37
39
  const elements = elementSchemas(options.elements);
38
40
  // Code-owned addresses from the site's own registry (spec 2026-09-29 nested paths §3): a code-owned root's path.
39
- const addresses = addressMap(codeAddresses({ locales, pages: options.pages, collections: options.collections }));
41
+ const codeOwned = codeAddresses({ locales, pages: options.pages, collections: options.collections });
42
+ const addresses = addressMap(codeOwned);
40
43
  const pathOpts = { locales, homePageSlug, addresses };
41
44
  /** The same addresses as the path index sees them: an entry's stored part joined to its collection's (spec 2026-09-29 anchored paths §2). */
42
45
  const anchors = { addresses, homePageSlug, defaultLocale: locales.default };
43
46
  const mounted = options.collections.filter((c) => c.path !== undefined);
47
+ // Every cached read keys on it (spec 2026-09-30 tester findings §3): an upgrade or a model change starts a fresh cache.
48
+ const cacheKey = `${SMOODLY_VERSION}:${modelKey({
49
+ // Whole schemas: a collection's order, tree or title field changes a read as its fields do (functions drop out in the hash).
50
+ collections: options.collections,
51
+ globals: (options.globals ?? []).map((g) => ({ name: g.name, fields: g.fields, section: g.section })),
52
+ sections: options.sections.map((x) => ("schema" in x ? x.schema : x)),
53
+ elements: (options.elements ?? []).map((x) => ("schema" in x ? x.schema : x)),
54
+ pages: options.pages.map((x) => x.schema),
55
+ locales, homePageSlug, styles, custom,
56
+ })}`;
44
57
  /** The resolver's fetcher, bound to a locale: the merged fields of that
45
58
  * locale — published ones (a versioned collection's snapshot) on the
46
59
  * public path, the live rows in draft mode. */
@@ -336,9 +349,33 @@ export function createSmoodlySite(options) {
336
349
  };
337
350
  },
338
351
  };
352
+ const codePath = (address) => (address === homePageSlug ? "/" : `/${address}`);
353
+ function codePage(locale, path) {
354
+ const spec = codeOwned.find((a) => a.template !== null && a.segments[locale] !== undefined && codePath(a.segments[locale]) === path);
355
+ if (!spec || !locales.supported.includes(locale))
356
+ return null;
357
+ const template = spec.template;
358
+ const withSeo = options.pages.some((x) => x.schema.kind === "page" && x.schema.name === template && x.schema.seo === true);
359
+ return {
360
+ kind: "page",
361
+ locale,
362
+ // No record yet: the id only has to be stable and never a stored one.
363
+ record: { id: `code:${template}`, parentId: null, template, fixed: spec.key, locales: {}, sort: null },
364
+ template,
365
+ tree: { zones: {} },
366
+ title: spec.title,
367
+ path,
368
+ fields: withDefaults(undefined, pageFields(template)),
369
+ ancestors: [],
370
+ children: [],
371
+ locales: Object.fromEntries(Object.entries(spec.segments).map(([l, address]) => [l, { slug: lastPart(address), path: codePath(address) }])),
372
+ ...(withSeo ? { seo: pageSeo(undefined, spec.title) } : {}),
373
+ };
374
+ }
339
375
  const site = {
340
376
  locales,
341
377
  homePageSlug,
378
+ cacheKey,
342
379
  styles,
343
380
  custom,
344
381
  locate: (locale, path) => options.stores().pages.resolvePath(locale, path),
@@ -352,6 +389,7 @@ export function createSmoodlySite(options) {
352
389
  const target = await site.locate(locale, path);
353
390
  return target && !target.redirect ? site.load(target, locale, path, opts) : null;
354
391
  },
392
+ codePage,
355
393
  ...api,
356
394
  };
357
395
  return site;
@@ -48,6 +48,8 @@ function toRecord(row) {
48
48
  function toVersion(row) {
49
49
  return { id: row.id, entryId: row.entry_id, locale: row.locale, fields: row.fields, savedAt: Date.parse(row.saved_at) };
50
50
  }
51
+ /** Rows per request when `list` reads a whole collection: under PostgREST's default cap. */
52
+ const LIST_PAGE = 1000;
51
53
  export class SupabaseEntryStore {
52
54
  constructor(db, collections, options = {}) {
53
55
  this.db = db;
@@ -391,47 +393,61 @@ export class SupabaseEntryStore {
391
393
  // EVERY locale row of each record (spec 2026-09-11 §3), ordered by locale
392
394
  // so a record's key order never depends on physical row order.
393
395
  const gate = options?.status !== undefined || options?.locale !== undefined;
394
- let query = this.db
395
- .from("entries")
396
- .select(gate ? `${SELECT}, gate:entry_locales!inner(locale)` : SELECT)
397
- .eq("collection", collection)
398
- .order("locale", { referencedTable: "entry_locales", ascending: true });
399
- if (gate) {
400
- query = query.eq("gate.locale", options.locale ?? this.locales.default);
401
- if (options.status !== undefined)
402
- query = query.eq("gate.status", options.status);
403
- }
404
- if (options?.parent !== undefined) {
405
- query = options.parent === null ? query.is("parent_id", null) : query.eq("parent_id", options.parent);
406
- }
407
- for (const [key, want] of Object.entries(options?.where ?? {})) {
408
- // Any-of compares the text form (`->>`). A scalar is JSONB containment;
409
- // an array-valued field (checkboxes, refList, list, tags) contains `[value]`,
410
- // a scalar field `value` — the memory adapter's matchesWhere agrees.
411
- if (Array.isArray(want)) {
412
- query = query.in(`fields->>${key}`, want.map(String));
396
+ // Built per request: without a `limit` the list is read in pages, because
397
+ // PostgREST caps one response (1000 rows by default) without saying so.
398
+ const build = () => {
399
+ let query = this.db
400
+ .from("entries")
401
+ .select(gate ? `${SELECT}, gate:entry_locales!inner(locale)` : SELECT)
402
+ .eq("collection", collection)
403
+ .order("locale", { referencedTable: "entry_locales", ascending: true });
404
+ if (gate) {
405
+ query = query.eq("gate.locale", options.locale ?? this.locales.default);
406
+ if (options.status !== undefined)
407
+ query = query.eq("gate.status", options.status);
408
+ }
409
+ if (options?.parent !== undefined) {
410
+ query = options.parent === null ? query.is("parent_id", null) : query.eq("parent_id", options.parent);
411
+ }
412
+ for (const [key, want] of Object.entries(options?.where ?? {})) {
413
+ // Any-of compares the text form (`->>`). A scalar is JSONB containment;
414
+ // an array-valued field (checkboxes, refList, list, tags) contains `[value]`,
415
+ // a scalar field `value` — the memory adapter's matchesWhere agrees.
416
+ if (Array.isArray(want)) {
417
+ query = query.in(`fields->>${key}`, want.map(String));
418
+ }
419
+ else {
420
+ const inArray = ARRAY_FIELD_TYPES.has(schema.fields[key]?.type ?? "");
421
+ query = query.contains("fields", { [key]: inArray ? [want] : want });
422
+ }
423
+ }
424
+ const order = options?.order ?? collectionOrder(schema);
425
+ if (order === "manual") {
426
+ // `id` last in both orders: a stable order, so no row falls between two pages.
427
+ query = query.order("sort", { ascending: true, nullsFirst: false }).order("created_at", { ascending: true }).order("id", { ascending: true });
413
428
  }
414
429
  else {
415
- const inArray = ARRAY_FIELD_TYPES.has(schema.fields[key]?.type ?? "");
416
- query = query.contains("fields", { [key]: inArray ? [want] : want });
430
+ // `fields->x`, not `->>x`: ordering by the jsonb VALUE of the SHARED
431
+ // field (an order field is never localized), matching the memory comparator.
432
+ const column = order.by === "createdAt" ? "created_at" : order.by === "updatedAt" ? "updated_at" : `fields->${order.by}`;
433
+ query = query.order(column, { ascending: order.direction === "asc", nullsFirst: false }).order("id", { ascending: true });
417
434
  }
418
- }
419
- const order = options?.order ?? collectionOrder(schema);
420
- if (order === "manual") {
421
- query = query.order("sort", { ascending: true, nullsFirst: false }).order("created_at", { ascending: true });
422
- }
423
- else {
424
- // `fields->x`, not `->>x`: ordering by the jsonb VALUE of the SHARED
425
- // field (an order field is never localized), matching the memory comparator.
426
- const column = order.by === "createdAt" ? "created_at" : order.by === "updatedAt" ? "updated_at" : `fields->${order.by}`;
427
- query = query.order(column, { ascending: order.direction === "asc", nullsFirst: false }).order("id", { ascending: true });
428
- }
435
+ return query;
436
+ };
437
+ const rows = (res) => {
438
+ if (res.error)
439
+ this.fail(res.error.message);
440
+ return res.data.map(({ gate: _gate, ...row }) => toRecord(row));
441
+ };
429
442
  if (typeof options?.limit === "number")
430
- query = query.limit(options.limit);
431
- const res = await query;
432
- if (res.error)
433
- this.fail(res.error.message);
434
- return res.data.map(({ gate: _gate, ...row }) => toRecord(row));
443
+ return rows(await build().limit(options.limit));
444
+ const all = [];
445
+ for (let from = 0;; from += LIST_PAGE) {
446
+ const page = rows(await build().range(from, from + LIST_PAGE - 1));
447
+ all.push(...page);
448
+ if (page.length < LIST_PAGE)
449
+ return all;
450
+ }
435
451
  }
436
452
  async query(collection, q) {
437
453
  const schema = this.schema(collection);
@@ -10,6 +10,12 @@ export declare class SupabaseMemberStore implements MemberStore {
10
10
  /** The admin API has no lookup by email: page through the users (tens, not thousands). */
11
11
  private userIdByEmail;
12
12
  invite(input: InviteInput): Promise<InviteResult>;
13
+ private upsertRow;
14
+ createAccount(input: {
15
+ email: string;
16
+ password: string;
17
+ role: MemberRole;
18
+ }): Promise<MemberRecord>;
13
19
  setRole(userId: string, role: MemberRole): Promise<MemberRecord>;
14
20
  remove(userId: string): Promise<void>;
15
21
  }
@@ -51,14 +51,32 @@ export class SupabaseMemberStore {
51
51
  else {
52
52
  throw new Error(`smoodly: ${sent.error.message}`);
53
53
  }
54
+ return { member: await this.upsertRow(userId, input.email, input.role), invited };
55
+ }
56
+ async upsertRow(userId, email, role) {
54
57
  const res = await this.db
55
58
  .from("members")
56
- .upsert({ user_id: userId, email: input.email, role: input.role }, { onConflict: "space_id,user_id" })
59
+ .upsert({ user_id: userId, email, role }, { onConflict: "space_id,user_id" })
57
60
  .select("user_id, email, role")
58
61
  .single();
59
62
  if (res.error)
60
63
  throw new Error(`smoodly: ${res.error.message}`);
61
- return { member: this.toRecord(res.data), invited };
64
+ return this.toRecord(res.data);
65
+ }
66
+ async createAccount(input) {
67
+ const created = await this.db.auth.admin.createUser({ email: input.email, password: input.password, email_confirm: true });
68
+ let userId;
69
+ if (!created.error) {
70
+ userId = created.data.user.id;
71
+ }
72
+ else if (created.error.code === "email_exists" || /already (been )?registered/i.test(created.error.message)) {
73
+ // An existing account keeps its password; it only gains the membership.
74
+ userId = await this.userIdByEmail(input.email);
75
+ }
76
+ else {
77
+ throw new Error(`smoodly: ${created.error.message}`);
78
+ }
79
+ return this.upsertRow(userId, input.email, input.role);
62
80
  }
63
81
  async setRole(userId, role) {
64
82
  const res = await this.db.from("members").update({ role }).eq("user_id", userId).select("user_id, email, role").maybeSingle();
@@ -8,7 +8,8 @@
8
8
  // rewritten through ONE RPC per write, so a collision rolls back.
9
9
  import { assertPublishable, assertSupportedLocale, localeRow, pageErrors, pageRefEdges, pathOptions, refSections, storeAnchors, } from "./store.js";
10
10
  import { SupabasePathIndex } from "./supabase-path-index.js";
11
- import { SCHEMA_VERSION_KEY, schemaBehind, schemaBehindMessage } from "./schema-version.js";
11
+ import { schemaBehind, schemaBehindMessage, secretKeyRefusedMessage } from "./schema-version.js";
12
+ import { readSchemaVersion } from "./schema-read.js";
12
13
  import { groupRedirects } from "./store.js";
13
14
  import { assertFresh, draftToken, StaleDraftError } from "./draft-token.js";
14
15
  import { assertDepth, assertSegment, isHomeNode, pagePathRows, rewriteTargets } from "./paths.js";
@@ -381,25 +382,26 @@ export class SupabasePageStore {
381
382
  resolvePath(locale, path) {
382
383
  if (!this.schemaChecked) {
383
384
  this.schemaChecked = true;
384
- void this.schemaVersion()
385
- .then((v) => {
386
- if (schemaBehind(v))
387
- console.warn(schemaBehindMessage(v));
385
+ // Once per store: a wrong key is named as a wrong key, not as an old schema (spec 2026-09-30 tester findings §4).
386
+ void readSchemaVersion(this.db)
387
+ .then((read) => {
388
+ if ("missing" in read)
389
+ console.warn(read.missing === "key" ? secretKeyRefusedMessage : schemaBehindMessage(null));
390
+ else if (schemaBehind(read.version))
391
+ console.warn(schemaBehindMessage(read.version));
388
392
  })
389
393
  .catch(() => { });
390
394
  }
391
395
  return this.paths.resolve(locale, path);
392
396
  }
393
397
  async schemaVersion() {
394
- const res = await this.db.from("smoodly_meta").select("value").eq("key", SCHEMA_VERSION_KEY).maybeSingle();
395
- if (res.error) {
396
- // PGRST205: not in PostgREST's schema cache; 42P01: undefined table.
397
- if (res.error.code === "PGRST205" || res.error.code === "42P01")
398
- return null;
399
- this.fail(res.error.message);
400
- }
401
- const n = Number(res.data?.value);
402
- return Number.isInteger(n) ? n : null;
398
+ const read = await readSchemaVersion(this.db);
399
+ if ("version" in read)
400
+ return read.version;
401
+ // The table exists and the row is hidden: the key is not a secret key.
402
+ if (read.missing === "key")
403
+ throw new Error(secretKeyRefusedMessage);
404
+ return null;
403
405
  }
404
406
  pathsOf(id) {
405
407
  return UUID_RE.test(id) ? this.paths.pathsOf({ kind: "page", id }) : Promise.resolve({});
package/dist/tags.d.ts CHANGED
@@ -17,3 +17,6 @@ export declare function addTags(current: readonly string[], input: string, opts?
17
17
  * entries disagree on a tag's casing the most used one wins, a tie going to plain string
18
18
  * order — never to the order the values arrived in, which is the store's business. */
19
19
  export declare function distinctTags(values: readonly unknown[]): string[];
20
+ /** A tags value as stored (spec 2026-09-30 tester findings §5): normalized, one per
21
+ * case-folded tag, each taking the spelling already used in the collection. */
22
+ export declare function canonicalTags(values: readonly unknown[], known: readonly string[]): string[];
package/dist/tags.js CHANGED
@@ -48,3 +48,19 @@ export function distinctTags(values) {
48
48
  const pick = (counts) => [...counts].sort(([a, n], [b, m]) => m - n || (a < b ? -1 : 1))[0][0];
49
49
  return [...variants.values()].map(pick).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
50
50
  }
51
+ /** A tags value as stored (spec 2026-09-30 tester findings §5): normalized, one per
52
+ * case-folded tag, each taking the spelling already used in the collection. */
53
+ export function canonicalTags(values, known) {
54
+ const out = [];
55
+ for (const value of values) {
56
+ if (typeof value !== "string")
57
+ continue;
58
+ const typed = normalizeTag(value);
59
+ if (typed === "")
60
+ continue;
61
+ const tag = known.find((k) => sameTag(k, typed)) ?? typed;
62
+ if (!out.some((t) => sameTag(t, tag)))
63
+ out.push(tag);
64
+ }
65
+ return out;
66
+ }
@@ -0,0 +1 @@
1
+ export declare const SMOODLY_VERSION = "0.0.39";
@@ -0,0 +1,2 @@
1
+ // Generated by scripts/write-version.mjs from package.json — do not edit.
2
+ export const SMOODLY_VERSION = "0.0.39";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smoodly",
3
- "version": "0.0.37",
3
+ "version": "0.0.39",
4
4
  "description": "Open-source, code-first CMS for Next.js on Supabase: define a component once, it becomes a typed, editable building block.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -54,8 +54,9 @@
54
54
  "bin"
55
55
  ],
56
56
  "scripts": {
57
- "build": "rm -rf dist && tsc -p tsconfig.build.json",
57
+ "build": "node scripts/write-version.mjs && rm -rf dist && tsc -p tsconfig.build.json",
58
58
  "prepublishOnly": "npm run build",
59
+ "pretest": "node scripts/write-version.mjs",
59
60
  "test": "vitest run",
60
61
  "test:watch": "vitest",
61
62
  "typecheck": "tsc --noEmit",
@@ -82,17 +83,19 @@
82
83
  },
83
84
  "dependencies": {
84
85
  "@supabase/supabase-js": "^2.113.0",
85
- "@tiptap/core": "3.31.3",
86
- "@tiptap/extension-blockquote": "3.31.3",
87
- "@tiptap/extension-bold": "3.31.3",
88
- "@tiptap/extension-heading": "3.31.3",
89
- "@tiptap/extension-italic": "3.31.3",
90
- "@tiptap/extension-link": "3.31.3",
91
- "@tiptap/extension-list": "3.31.3",
92
- "@tiptap/pm": "3.31.3",
93
- "@tiptap/react": "3.31.3",
94
- "@tiptap/starter-kit": "3.31.3",
95
- "@tiptap/suggestion": "3.31.3",
86
+ "@tiptap/core": "3.31.4",
87
+ "@tiptap/extension-blockquote": "3.31.4",
88
+ "@tiptap/extension-bold": "3.31.4",
89
+ "@tiptap/extension-bubble-menu": "3.31.4",
90
+ "@tiptap/extension-floating-menu": "3.31.4",
91
+ "@tiptap/extension-heading": "3.31.4",
92
+ "@tiptap/extension-italic": "3.31.4",
93
+ "@tiptap/extension-link": "3.31.4",
94
+ "@tiptap/extension-list": "3.31.4",
95
+ "@tiptap/pm": "3.31.4",
96
+ "@tiptap/react": "3.31.4",
97
+ "@tiptap/starter-kit": "3.31.4",
98
+ "@tiptap/suggestion": "3.31.4",
96
99
  "lucide-react": "^1.41.0",
97
100
  "tsx": "^4.23.15"
98
101
  }