smoodly 0.0.38 → 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.
@@ -1,5 +1,5 @@
1
1
  import { entryErrors, entryFieldsIn, entryTitleIn } from "../entry-store.js";
2
- import { chainOf, isSegment, pagePathIn } from "../paths.js";
2
+ import { chainOf, isSegment, pagePathIn, slugify } from "../paths.js";
3
3
  import { servedAnchor } from "../anchors.js";
4
4
  import { canonicalTags, distinctTags } from "../tags.js";
5
5
  import { can, isMemberRole, normalizeRole } from "../permissions.js";
@@ -112,6 +112,18 @@ const classify = (e) => {
112
112
  return err("conflict", message);
113
113
  return err("internal", message);
114
114
  };
115
+ /** A new entry without a slug gets one from the field `f.slug({ from })` follows, as the admin's form
116
+ * fills it (spec 2026-09-28 ASCII slugs §5), so a script needs no slug of its own. A given slug stays. */
117
+ function followSlug(fields, descriptors) {
118
+ const d = descriptors.slug;
119
+ if (d?.type !== "slug" || d.from === undefined)
120
+ return fields;
121
+ if (typeof fields.slug === "string" && fields.slug !== "")
122
+ return fields;
123
+ const source = fields[d.from];
124
+ const slug = typeof source === "string" ? slugify(source) : "";
125
+ return slug === "" ? fields : { ...fields, slug };
126
+ }
115
127
  export function createAdminOps(deps) {
116
128
  const { config, pages, entries, effects } = deps;
117
129
  const href = deps.href ?? ((path) => path);
@@ -783,7 +795,7 @@ export function createAdminOps(deps) {
783
795
  const descriptors = collectionFields(collection);
784
796
  if (!descriptors)
785
797
  return err("not_found", `smoodly: no collection named "${collection}".`);
786
- const created = await entries.create(collection, withDefaults(await foldTags(collection, input.locale, input.fields, descriptors), descriptors), { locale: input.locale, parentId: input.parentId ?? null }, by);
798
+ const created = await entries.create(collection, withDefaults(await foldTags(collection, input.locale, followSlug(input.fields, descriptors), descriptors), descriptors), { locale: input.locale, parentId: input.parentId ?? null }, by);
787
799
  expireCollection(collection);
788
800
  return ok(created);
789
801
  }),
@@ -54,6 +54,10 @@ export declare function createSmoodlyContent(deps: ContentDeps): {
54
54
  fields?: Record<string, unknown>;
55
55
  }): Promise<EntryRecord>;
56
56
  publish: (collection: string, id: string, locale: string) => Promise<EntryRecord>;
57
+ list: (collection: string, options?: {
58
+ where?: Record<string, unknown>;
59
+ locale?: string;
60
+ }) => Promise<EntryRecord[]>;
57
61
  find(collection: string, match: {
58
62
  slug: string;
59
63
  locale?: string;
@@ -65,6 +65,25 @@ export function createSmoodlyContent(deps) {
65
65
  async function updateEntry(collection, id, locale, fields) {
66
66
  return must(ops.entries.save(collection, id, locale, fields));
67
67
  }
68
+ /** The entries present in `locale` (the default language unless given) whose fields match every `where` key:
69
+ * shared and that locale's fields and its slug, equal, or held by an array field (tags, refList). */
70
+ async function listEntries(collection, options = {}) {
71
+ if (!deps.config.registry.collections?.some((c) => c.name === collection)) {
72
+ throw new SmoodlyContentError({ code: "not_found", message: `smoodly: no collection named "${collection}".` });
73
+ }
74
+ const locale = options.locale ?? def;
75
+ const where = Object.entries(options.where ?? {});
76
+ return (await must(ops.entries.list(collection))).filter((e) => {
77
+ const row = e.locales[locale];
78
+ if (!row)
79
+ return false;
80
+ const fields = { ...e.fields, ...row.fields, slug: row.slug };
81
+ return where.every(([key, want]) => {
82
+ const value = fields[key];
83
+ return Array.isArray(value) ? value.includes(want) : value === want;
84
+ });
85
+ });
86
+ }
68
87
  return {
69
88
  pages: {
70
89
  create: (input) => must(ops.pages.create(input)),
@@ -105,10 +124,9 @@ export function createSmoodlyContent(deps) {
105
124
  return options.fields ? updateEntry(collection, id, locale, options.fields) : added;
106
125
  },
107
126
  publish: (collection, id, locale) => must(ops.entries.setStatus(collection, id, locale, "published")),
127
+ list: listEntries,
108
128
  async find(collection, match) {
109
- const locale = match.locale ?? def;
110
- const all = await must(ops.entries.list(collection));
111
- return all.find((e) => e.locales[locale]?.slug === match.slug) ?? null;
129
+ return (await listEntries(collection, { where: { slug: match.slug }, locale: match.locale }))[0] ?? null;
112
130
  },
113
131
  },
114
132
  globals: {
@@ -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);
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const SMOODLY_VERSION = "0.0.38";
1
+ export declare const SMOODLY_VERSION = "0.0.39";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Generated by scripts/write-version.mjs from package.json — do not edit.
2
- export const SMOODLY_VERSION = "0.0.38";
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.38",
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": {