smoodly 0.0.46 → 0.0.48

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 (64) hide show
  1. package/dist/admin/browser/main.js +2 -0
  2. package/dist/admin/client-ops.js +1 -0
  3. package/dist/admin/delivery-mode.d.ts +5 -0
  4. package/dist/admin/delivery-mode.js +20 -0
  5. package/dist/admin/editor/EditorView.js +3 -0
  6. package/dist/admin/files.generated.js +1 -1
  7. package/dist/admin/handler.d.ts +5 -1
  8. package/dist/admin/handler.js +2 -1
  9. package/dist/admin/health.d.ts +4 -0
  10. package/dist/admin/ops-impl.d.ts +13 -0
  11. package/dist/admin/ops-impl.js +48 -2
  12. package/dist/admin/ops.d.ts +11 -0
  13. package/dist/admin/page-data.d.ts +2 -0
  14. package/dist/admin/shell/AdminApp.js +47 -1
  15. package/dist/admin/shell/ContentHealthPage.js +3 -1
  16. package/dist/admin/shell/EntryForm.js +3 -0
  17. package/dist/admin/shell/GlobalForm.js +5 -1
  18. package/dist/admin/shell/IconRail.d.ts +8 -1
  19. package/dist/admin/shell/IconRail.js +8 -3
  20. package/dist/admin/shell/deploy-tile.d.ts +20 -0
  21. package/dist/admin/shell/deploy-tile.js +35 -0
  22. package/dist/admin/ui/PublishControl.js +3 -1
  23. package/dist/admin/ui/theme.js +12 -0
  24. package/dist/astro/deploy-deps.d.ts +10 -0
  25. package/dist/astro/deploy-deps.js +8 -0
  26. package/dist/astro/index.d.ts +9 -4
  27. package/dist/astro/index.js +75 -15
  28. package/dist/astro/middleware.js +4 -0
  29. package/dist/astro/routes/admin.js +2 -1
  30. package/dist/astro/routes/preview.astro +17 -5
  31. package/dist/astro/routes/static-page.astro +21 -0
  32. package/dist/astro/runtime.d.ts +6 -0
  33. package/dist/astro/runtime.js +4 -1
  34. package/dist/astro/server.d.ts +6 -0
  35. package/dist/astro/server.js +7 -1
  36. package/dist/astro/static.d.ts +36 -0
  37. package/dist/astro/static.js +59 -0
  38. package/dist/cli/load-config.d.ts +3 -0
  39. package/dist/cli/load-config.js +8 -1
  40. package/dist/cli/migrate.d.ts +10 -0
  41. package/dist/cli/migrate.js +22 -1
  42. package/dist/cli/space-io.js +3 -3
  43. package/dist/deploy-state.d.ts +28 -0
  44. package/dist/deploy-state.js +31 -0
  45. package/dist/document-stamp.d.ts +7 -0
  46. package/dist/document-stamp.js +48 -0
  47. package/dist/project.d.ts +22 -3
  48. package/dist/project.js +33 -12
  49. package/dist/published.d.ts +50 -4
  50. package/dist/published.js +46 -7
  51. package/dist/schema-version.d.ts +1 -1
  52. package/dist/schema-version.js +1 -1
  53. package/dist/site.d.ts +2 -0
  54. package/dist/site.js +53 -11
  55. package/dist/sql-space.d.ts +4 -2
  56. package/dist/sql-space.js +4 -2
  57. package/dist/sql.js +20 -1
  58. package/dist/supabase-published-store.d.ts +24 -3
  59. package/dist/supabase-published-store.js +104 -5
  60. package/dist/version.d.ts +1 -1
  61. package/dist/version.js +1 -1
  62. package/migrations/20261008120000_smoodly_v10.sql +47 -0
  63. package/migrations/20261009120000_smoodly_v11.sql +49 -0
  64. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  import type { SupabaseClient } from "@supabase/supabase-js";
2
2
  import type { CollectionSchema } from "./collections.ts";
3
- import type { PublishedEntryRow, PublishedKey, PublishedPageRow, PublishedQuery, PublishedRoute, PublishedStore, PublishedTreeRow } from "./published.ts";
3
+ import type { PublishedEntryRow, PublishedKey, PublishedPageRow, PublishedQuery, PublishedRoute, PublishedStore, PublishedTreeRow, PutOptions } from "./published.ts";
4
4
  export declare class SupabasePublishedStore implements PublishedStore {
5
5
  private db;
6
6
  private schemas;
@@ -8,8 +8,8 @@ export declare class SupabasePublishedStore implements PublishedStore {
8
8
  /** `collections` encode a `where` on an array field as containment, as SupabaseEntryStore.query does. */
9
9
  constructor(db: SupabaseClient, collections?: CollectionSchema<any>[]);
10
10
  private fail;
11
- putPages(rows: PublishedPageRow[]): Promise<void>;
12
- putEntries(rows: PublishedEntryRow[]): Promise<void>;
11
+ putPages(rows: PublishedPageRow[], options?: PutOptions): Promise<void>;
12
+ putEntries(rows: PublishedEntryRow[], options?: PutOptions): Promise<void>;
13
13
  /** The space this client writes in (the service role's is the default): a path delete must not reach past it,
14
14
  * since the service role passes every policy. Cached for the life of the client; a failed lookup is not. */
15
15
  private space;
@@ -20,6 +20,27 @@ export declare class SupabasePublishedStore implements PublishedStore {
20
20
  /** One request per kind and locale filter: the keys without a locale go in one in(). */
21
21
  private eachGroup;
22
22
  remove(keys: PublishedKey[]): Promise<void>;
23
+ lastRemovalAt(): Promise<number | null>;
24
+ /** The space's recorded stamp (DESIGN §3): one row per space in published_models; the key's space, or the
25
+ * service role's default one. */
26
+ recordedModel(): Promise<string | null>;
27
+ recordModel(model: string): Promise<void>;
28
+ /** Four head counts (no rows travel): each table's rows, and its rows at another stamp. The policies scope a
29
+ * write key to its space; the service role counts the default space's rows by the explicit filter. */
30
+ countBehind(model: string): Promise<{
31
+ behind: number;
32
+ total: number;
33
+ }>;
34
+ /** The static build's watermark (spec 2026-10-09 static mode §5): the newest published_at over both tables. */
35
+ newestPublishedAt(): Promise<number | null>;
36
+ /** Rows published strictly after `since`: the Deploy tile's "changes waiting". Two head counts. */
37
+ countPublishedAfter(since: number): Promise<number>;
38
+ /** The space's last Deploy press, one row in space_deploys (schema v11). */
39
+ deployRequest(): Promise<{
40
+ at: number;
41
+ by: string | null;
42
+ } | null>;
43
+ requestDeploy(by: string | null, at?: number): Promise<void>;
23
44
  markStale(keys: PublishedKey[]): Promise<void>;
24
45
  /** A route row is for serving, not for the tree: a page route carries the
25
46
  * document, model, stale flag and tags; its tree columns are empty, and an
@@ -13,6 +13,13 @@ const fromPage = (r) => ({
13
13
  id: r.page_id, locale: r.locale, parentId: r.parent_id, sort: num(r.sort), path: r.path, title: r.title, hasPage: r.has_page,
14
14
  linkFields: r.link_fields, document: r.document, tags: r.tags, model: r.model, stale: r.stale, publishedAt: Date.parse(r.published_at),
15
15
  });
16
+ /** `keep` strips published_at from an upsert's payload (PutOptions): the row keeps its own, a new row is dated by the default. */
17
+ function keeping(row, options) {
18
+ if (!options?.keep)
19
+ return row;
20
+ const { published_at: _at, ...rest } = row;
21
+ return rest;
22
+ }
16
23
  const toPage = (r) => ({
17
24
  page_id: r.id, locale: r.locale, parent_id: r.parentId, sort: r.sort, path: r.path, title: r.title, has_page: r.hasPage,
18
25
  link_fields: r.linkFields, document: r.document, tags: r.tags, model: r.model, stale: r.stale, published_at: iso(r.publishedAt),
@@ -36,20 +43,21 @@ export class SupabasePublishedStore {
36
43
  fail(message) {
37
44
  throw new Error(`smoodly: published store: ${message}`);
38
45
  }
39
- async putPages(rows) {
46
+ async putPages(rows, options) {
40
47
  for (let i = 0; i < rows.length; i += CHUNK) {
41
48
  const chunk = rows.slice(i, i + CHUNK);
42
49
  await this.vacate("published_pages", "page_id", chunk);
43
- const res = await this.db.from("published_pages").upsert(chunk.map(toPage), { onConflict: "page_id,locale" });
50
+ // `keep`: published_at leaves the payload, so an updated row keeps its own and an inserted one takes the default (now).
51
+ const res = await this.db.from("published_pages").upsert(chunk.map((r) => keeping(toPage(r), options)), { onConflict: "page_id,locale" });
44
52
  if (res.error)
45
53
  this.fail(res.error.message);
46
54
  }
47
55
  }
48
- async putEntries(rows) {
56
+ async putEntries(rows, options) {
49
57
  for (let i = 0; i < rows.length; i += CHUNK) {
50
58
  const chunk = rows.slice(i, i + CHUNK);
51
59
  await this.vacate("published_entries", "entry_id", chunk);
52
- const res = await this.db.from("published_entries").upsert(chunk.map(toEntry), { onConflict: "entry_id,locale" });
60
+ const res = await this.db.from("published_entries").upsert(chunk.map((r) => keeping(toEntry(r), options)), { onConflict: "entry_id,locale" });
53
61
  if (res.error)
54
62
  this.fail(res.error.message);
55
63
  }
@@ -108,14 +116,105 @@ export class SupabasePublishedStore {
108
116
  }
109
117
  }
110
118
  async remove(keys) {
119
+ if (keys.length === 0)
120
+ return;
121
+ let deleted = 0;
111
122
  await this.eachGroup(keys, async (table, column, ids, locale) => {
112
- let q = this.db.from(table).delete().in(column, ids);
123
+ let q = this.db.from(table).delete({ count: "exact" }).in(column, ids);
113
124
  if (locale !== undefined)
114
125
  q = q.eq("locale", locale);
115
126
  const res = await q;
116
127
  if (res.error)
117
128
  this.fail(res.error.message);
129
+ deleted += res.count ?? 0;
118
130
  });
131
+ // A row that went is a change the count of newer rows cannot see (spec 2026-10-09 static mode §5): the space's
132
+ // row keeps when one last happened. A key with no row (a publish vacating a locale that never had one) is no
133
+ // change. The press's columns are absent from the payload, so they stay as they are.
134
+ if (deleted === 0)
135
+ return;
136
+ const res = await this.db.from("space_deploys").upsert({ space_id: await this.space(), removed_at: new Date().toISOString() }, { onConflict: "space_id" });
137
+ if (res.error)
138
+ this.fail(res.error.message);
139
+ }
140
+ async lastRemovalAt() {
141
+ const res = await this.db.from("space_deploys").select("removed_at").eq("space_id", await this.space()).maybeSingle();
142
+ if (res.error)
143
+ this.fail(res.error.message);
144
+ const at = res.data?.removed_at;
145
+ return at ? Date.parse(at) : null;
146
+ }
147
+ /** The space's recorded stamp (DESIGN §3): one row per space in published_models; the key's space, or the
148
+ * service role's default one. */
149
+ async recordedModel() {
150
+ const res = await this.db.from("published_models").select("model").eq("space_id", await this.space()).maybeSingle();
151
+ if (res.error)
152
+ this.fail(res.error.message);
153
+ return res.data?.model ?? null;
154
+ }
155
+ async recordModel(model) {
156
+ const res = await this.db.from("published_models")
157
+ .upsert({ space_id: await this.space(), model, recorded_at: new Date().toISOString() }, { onConflict: "space_id" });
158
+ if (res.error)
159
+ this.fail(res.error.message);
160
+ }
161
+ /** Four head counts (no rows travel): each table's rows, and its rows at another stamp. The policies scope a
162
+ * write key to its space; the service role counts the default space's rows by the explicit filter. */
163
+ async countBehind(model) {
164
+ const space = await this.space();
165
+ const count = async (table, behind) => {
166
+ let q = this.db.from(table).select("*", { count: "exact", head: true }).eq("space_id", space);
167
+ if (behind)
168
+ q = q.neq("model", model);
169
+ const res = await q;
170
+ if (res.error)
171
+ this.fail(res.error.message);
172
+ return res.count ?? 0;
173
+ };
174
+ const [pages, entries, pagesBehind, entriesBehind] = await Promise.all([
175
+ count("published_pages", false), count("published_entries", false), count("published_pages", true), count("published_entries", true),
176
+ ]);
177
+ return { behind: pagesBehind + entriesBehind, total: pages + entries };
178
+ }
179
+ /** The static build's watermark (spec 2026-10-09 static mode §5): the newest published_at over both tables. */
180
+ async newestPublishedAt() {
181
+ const space = await this.space();
182
+ const latest = async (table) => {
183
+ const res = await this.db.from(table).select("published_at").eq("space_id", space).order("published_at", { ascending: false }).limit(1).maybeSingle();
184
+ if (res.error)
185
+ this.fail(res.error.message);
186
+ const at = res.data?.published_at;
187
+ return at ? Date.parse(at) : null;
188
+ };
189
+ const [p, e] = await Promise.all([latest("published_pages"), latest("published_entries")]);
190
+ return p === null && e === null ? null : Math.max(p ?? 0, e ?? 0);
191
+ }
192
+ /** Rows published strictly after `since`: the Deploy tile's "changes waiting". Two head counts. */
193
+ async countPublishedAfter(since) {
194
+ const space = await this.space();
195
+ const count = async (table) => {
196
+ const res = await this.db.from(table).select("*", { count: "exact", head: true }).eq("space_id", space).gt("published_at", new Date(since).toISOString());
197
+ if (res.error)
198
+ this.fail(res.error.message);
199
+ return res.count ?? 0;
200
+ };
201
+ const [p, e] = await Promise.all([count("published_pages"), count("published_entries")]);
202
+ return p + e;
203
+ }
204
+ /** The space's last Deploy press, one row in space_deploys (schema v11). */
205
+ async deployRequest() {
206
+ const res = await this.db.from("space_deploys").select("requested_at, requested_by").eq("space_id", await this.space()).maybeSingle();
207
+ if (res.error)
208
+ this.fail(res.error.message);
209
+ // A removal may have made the row before any press: no requested_at is no press.
210
+ const row = res.data;
211
+ return row?.requested_at ? { at: Date.parse(row.requested_at), by: row.requested_by } : null;
212
+ }
213
+ async requestDeploy(by, at = Date.now()) {
214
+ const res = await this.db.from("space_deploys")
215
+ .upsert({ space_id: await this.space(), requested_at: new Date(at).toISOString(), requested_by: by }, { onConflict: "space_id" });
216
+ if (res.error)
217
+ this.fail(res.error.message);
119
218
  }
120
219
  async markStale(keys) {
121
220
  await this.eachGroup(keys, async (table, column, ids, locale) => {
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const SMOODLY_VERSION = "0.0.46";
1
+ export declare const SMOODLY_VERSION = "0.0.48";
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.46";
2
+ export const SMOODLY_VERSION = "0.0.48";
@@ -0,0 +1,47 @@
1
+ -- Smoodly core schema, version 10 (DESIGN §3, the document stamp, decided 2026-10-08).
2
+ -- Shipped in the smoodly package; `npx smoodly migrate` copies it into supabase/migrations/.
3
+ -- Never edit a published file: the next change is the next file.
4
+ -- The delta over version 9: published_models, one row per space holding the
5
+ -- document stamp the space recorded as current, so only the deployment at that
6
+ -- stamp rewrites delivery rows (a preview deployment on a newer package serves
7
+ -- assembled documents and writes nothing). Additive: no function changes.
8
+
9
+ set client_min_messages = warning;
10
+
11
+ -- The document stamp a space recorded as current: set by `npx smoodly rebuild` and by the first writer
12
+ -- into an empty record; only a reader at this stamp rewrites published_pages and published_entries.
13
+ create table if not exists "published_models" (
14
+ "space_id" uuid primary key default '00000000-0000-0000-0000-000000000000',
15
+ "model" text not null,
16
+ "recorded_at" timestamptz not null default now()
17
+ );
18
+
19
+ alter table "published_models" enable row level security;
20
+
21
+ -- Stamped with the caller's space on insert, as every content table.
22
+ drop trigger if exists "smoodly_a_set_space" on "published_models";
23
+ create trigger "smoodly_a_set_space" before insert on "published_models"
24
+ for each row execute function "smoodly_set_space"();
25
+
26
+ -- A write key reads and writes its own space's record; no other key reads (spec 2026-09-29 content API contract §8).
27
+ drop policy if exists "smoodly_write" on "published_models";
28
+
29
+ drop policy if exists "smoodly_insert" on "published_models";
30
+ create policy "smoodly_insert" on "published_models" for insert
31
+ with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
32
+
33
+ drop policy if exists "smoodly_update" on "published_models";
34
+ create policy "smoodly_update" on "published_models" for update
35
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
36
+ with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
37
+
38
+ drop policy if exists "smoodly_delete" on "published_models";
39
+ create policy "smoodly_delete" on "published_models" for delete
40
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
41
+
42
+ drop policy if exists "smoodly_read" on "published_models";
43
+ create policy "smoodly_read" on "published_models" for select
44
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
45
+
46
+ insert into "smoodly_meta" ("key", "value") values ('schema_version', '10')
47
+ on conflict ("key") do update set "value" = excluded."value";
@@ -0,0 +1,49 @@
1
+ -- Smoodly core schema, version 11 (DESIGN §3, static mode, decided 2026-10-09).
2
+ -- Shipped in the smoodly package; `npx smoodly migrate` copies it into supabase/migrations/.
3
+ -- Never edit a published file: the next change is the next file.
4
+ -- The delta over version 10: space_deploys, one row per space holding the last
5
+ -- Deploy press (static mode's button), so a serverless function can tell a
6
+ -- build in flight from one that landed. Additive: no function changes.
7
+
8
+ set client_min_messages = warning;
9
+
10
+ -- The last Deploy press of a space (spec 2026-10-09 static mode §7): who pressed and when; the admin
11
+ -- compares it with the build marker the live site serves to show building, landed or not landed.
12
+ create table if not exists "space_deploys" (
13
+ "space_id" uuid primary key default '00000000-0000-0000-0000-000000000000',
14
+ -- Null until the first press: a removal may make the row first.
15
+ "requested_at" timestamptz,
16
+ "requested_by" text,
17
+ -- When a delivery row last went (an unpublish, a delete): a change the count of newer rows cannot see.
18
+ "removed_at" timestamptz
19
+ );
20
+
21
+ alter table "space_deploys" enable row level security;
22
+
23
+ -- Stamped with the caller's space on insert, as every content table.
24
+ drop trigger if exists "smoodly_a_set_space" on "space_deploys";
25
+ create trigger "smoodly_a_set_space" before insert on "space_deploys"
26
+ for each row execute function "smoodly_set_space"();
27
+
28
+ -- A write key reads and writes its own space's row; no other key reads (spec 2026-09-29 content API contract §8).
29
+ drop policy if exists "smoodly_write" on "space_deploys";
30
+
31
+ drop policy if exists "smoodly_insert" on "space_deploys";
32
+ create policy "smoodly_insert" on "space_deploys" for insert
33
+ with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
34
+
35
+ drop policy if exists "smoodly_update" on "space_deploys";
36
+ create policy "smoodly_update" on "space_deploys" for update
37
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write')
38
+ with check ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
39
+
40
+ drop policy if exists "smoodly_delete" on "space_deploys";
41
+ create policy "smoodly_delete" on "space_deploys" for delete
42
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
43
+
44
+ drop policy if exists "smoodly_read" on "space_deploys";
45
+ create policy "smoodly_read" on "space_deploys" for select
46
+ using ("space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"()) and (select "smoodly_key_kind"()) = 'write');
47
+
48
+ insert into "smoodly_meta" ("key", "value") values ('schema_version', '11')
49
+ on conflict ("key") do update set "value" = excluded."value";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smoodly",
3
- "version": "0.0.46",
3
+ "version": "0.0.48",
4
4
  "description": "Open-source, code-first CMS for Astro and Next.js on Supabase: define a component once, it becomes a typed, editable building block.",
5
5
  "license": "MIT",
6
6
  "repository": {