@growth-labs/cms 0.1.2 → 0.2.0
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/README.md +146 -0
- package/dist/engine/index.d.ts +2 -1
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +4 -2
- package/dist/engine/index.js.map +1 -1
- package/dist/engine/publication.d.ts +210 -0
- package/dist/engine/publication.d.ts.map +1 -0
- package/dist/engine/publication.js +1436 -0
- package/dist/engine/publication.js.map +1 -0
- package/dist/engine/slug-redirects.d.ts +16 -0
- package/dist/engine/slug-redirects.d.ts.map +1 -1
- package/dist/engine/slug-redirects.js +34 -0
- package/dist/engine/slug-redirects.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +5 -3
- package/dist/routes/content.js.map +1 -1
- package/dist/schema/index.d.ts +1 -1
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/migrations.d.ts.map +1 -1
- package/dist/schema/migrations.js +28 -0
- package/dist/schema/migrations.js.map +1 -1
- package/dist/schema/tables.d.ts +1 -1
- package/dist/schema/tables.d.ts.map +1 -1
- package/dist/schema/tables.js +1 -0
- package/dist/schema/tables.js.map +1 -1
- package/dist/schema/types.d.ts +20 -0
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/types.js.map +1 -1
- package/migrations/0018_content_publication_attempts.sql +23 -0
- package/package.json +1 -1
- package/src/engine/index.ts +45 -2
- package/src/engine/publication.ts +2152 -0
- package/src/engine/slug-redirects.ts +42 -0
- package/src/index.ts +3 -0
- package/src/routes/content.ts +5 -3
- package/src/schema/index.ts +3 -0
- package/src/schema/migrations.ts +28 -0
- package/src/schema/tables.ts +1 -0
- package/src/schema/types.ts +31 -0
|
@@ -32,3 +32,45 @@ export async function recordSlugRedirect(
|
|
|
32
32
|
.bind(oldSlug, contentId)
|
|
33
33
|
.run()
|
|
34
34
|
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Drop any redirect row for `slug`. Called when a rename makes `slug` live
|
|
38
|
+
* again (rename-back, or reuse of another item's retired slug): a live slug
|
|
39
|
+
* must never also be a redirect source, or a stale row could shadow it after
|
|
40
|
+
* a later unpublish — the ON CONFLICT DO NOTHING in recordSlugRedirect would
|
|
41
|
+
* otherwise preserve the stale mapping forever.
|
|
42
|
+
*/
|
|
43
|
+
export async function clearSlugRedirect(db: D1Database, slug: string): Promise<void> {
|
|
44
|
+
await db.prepare('DELETE FROM content_slug_redirects WHERE old_slug = ?').bind(slug).run()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Redirect bookkeeping for a slug rename, applied atomically: record
|
|
49
|
+
* `priorSlug` as a redirect to `contentId` AND drop any stale redirect row
|
|
50
|
+
* for `newSlug` (live again as of this rename). Batched so both mutations
|
|
51
|
+
* commit together or not at all — a partial failure must never leave a live
|
|
52
|
+
* slug registered as a redirect source.
|
|
53
|
+
*/
|
|
54
|
+
export async function applySlugRenameRedirects(
|
|
55
|
+
db: D1Database,
|
|
56
|
+
priorSlug: string,
|
|
57
|
+
newSlug: string,
|
|
58
|
+
contentId: string,
|
|
59
|
+
): Promise<void> {
|
|
60
|
+
// Equal slugs would make the DELETE cancel the INSERT within the batch —
|
|
61
|
+
// silent redirect loss for any caller without the route's own guard.
|
|
62
|
+
if (priorSlug === newSlug) return
|
|
63
|
+
await db.batch([
|
|
64
|
+
// Upsert, not DO NOTHING: if a stale row for priorSlug already exists
|
|
65
|
+
// (data predating this invariant), the rename is the freshest truth
|
|
66
|
+
// about which item last owned that slug.
|
|
67
|
+
db
|
|
68
|
+
.prepare(
|
|
69
|
+
`INSERT INTO content_slug_redirects (old_slug, content_id)
|
|
70
|
+
VALUES (?, ?)
|
|
71
|
+
ON CONFLICT(old_slug) DO UPDATE SET content_id = excluded.content_id`,
|
|
72
|
+
)
|
|
73
|
+
.bind(priorSlug, contentId),
|
|
74
|
+
db.prepare('DELETE FROM content_slug_redirects WHERE old_slug = ?').bind(newSlug),
|
|
75
|
+
])
|
|
76
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -56,6 +56,9 @@ export {
|
|
|
56
56
|
type ContentContributorRow,
|
|
57
57
|
type ContentImportRow,
|
|
58
58
|
type ContentItemRow,
|
|
59
|
+
type ContentPublicationAttemptRow,
|
|
60
|
+
type ContentPublicationAttemptState,
|
|
61
|
+
type ContentPublicationOperation,
|
|
59
62
|
type ContentRelationRow,
|
|
60
63
|
type ContentRevisionRow,
|
|
61
64
|
type ContentSlugRedirectRow,
|
package/src/routes/content.ts
CHANGED
|
@@ -44,7 +44,7 @@ import {
|
|
|
44
44
|
listRevisions,
|
|
45
45
|
restoreRevision,
|
|
46
46
|
} from '../engine/revisions.js'
|
|
47
|
-
import {
|
|
47
|
+
import { applySlugRenameRedirects } from '../engine/slug-redirects.js'
|
|
48
48
|
import { softDeleteContent } from '../engine/soft-delete.js'
|
|
49
49
|
import type { ContentStatus } from '../schema/types.js'
|
|
50
50
|
import { type ContentAction, canPerformContentAction } from './authz-matrix.js'
|
|
@@ -1123,9 +1123,11 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1123
1123
|
const podcastDispatch =
|
|
1124
1124
|
existing.type === 'podcast' ? await maybeDispatchPodcastToFoundry(ctx, id) : null
|
|
1125
1125
|
|
|
1126
|
-
// If the slug changed, record the old slug as a redirect (spec §8)
|
|
1126
|
+
// If the slug changed, record the old slug as a redirect (spec §8)
|
|
1127
|
+
// and drop any stale redirect row for the now-live slug — atomically,
|
|
1128
|
+
// so a live slug is never also a redirect source (rename-back safety).
|
|
1127
1129
|
if (updated.slug !== priorSlug) {
|
|
1128
|
-
await
|
|
1130
|
+
await applySlugRenameRedirects(ctx.db, priorSlug, updated.slug, id)
|
|
1129
1131
|
}
|
|
1130
1132
|
|
|
1131
1133
|
return json({
|
package/src/schema/index.ts
CHANGED
package/src/schema/migrations.ts
CHANGED
|
@@ -713,6 +713,34 @@ CREATE INDEX IF NOT EXISTS idx_content_items_topic_status_type_published
|
|
|
713
713
|
ON content_items(primary_topic, status, type, published_at DESC);
|
|
714
714
|
CREATE INDEX IF NOT EXISTS idx_content_items_published_at ON content_items(published_at DESC);
|
|
715
715
|
CREATE INDEX IF NOT EXISTS idx_content_items_deleted_at ON content_items(deleted_at);
|
|
716
|
+
`,
|
|
717
|
+
},
|
|
718
|
+
{
|
|
719
|
+
id: '0018_content_publication_attempts',
|
|
720
|
+
sql: `
|
|
721
|
+
CREATE TABLE content_publication_attempts (
|
|
722
|
+
id TEXT PRIMARY KEY,
|
|
723
|
+
operation TEXT NOT NULL CHECK (operation IN ('publish','rollback')),
|
|
724
|
+
content_id TEXT NOT NULL,
|
|
725
|
+
revision_id TEXT NOT NULL,
|
|
726
|
+
previous_revision_id TEXT,
|
|
727
|
+
state TEXT NOT NULL CHECK (state IN ('preparing','prepared','committing','pending_retry','committed','superseded','aborting','aborted','abort_failed')),
|
|
728
|
+
surface_names_json TEXT NOT NULL DEFAULT '[]',
|
|
729
|
+
pending_surface_names_json TEXT NOT NULL DEFAULT '[]',
|
|
730
|
+
prepared_surface_names_json TEXT NOT NULL DEFAULT '[]',
|
|
731
|
+
receipts_json TEXT NOT NULL DEFAULT '[]',
|
|
732
|
+
version INTEGER NOT NULL DEFAULT 0 CHECK (version >= 0),
|
|
733
|
+
package_version TEXT,
|
|
734
|
+
last_error TEXT,
|
|
735
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
736
|
+
updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
737
|
+
FOREIGN KEY (content_id) REFERENCES content_items(id) ON DELETE CASCADE,
|
|
738
|
+
FOREIGN KEY (revision_id) REFERENCES content_revisions(id) ON DELETE CASCADE
|
|
739
|
+
);
|
|
740
|
+
CREATE INDEX idx_content_publication_attempts_recovery
|
|
741
|
+
ON content_publication_attempts(state, updated_at);
|
|
742
|
+
CREATE INDEX idx_content_publication_attempts_content
|
|
743
|
+
ON content_publication_attempts(content_id, created_at DESC);
|
|
716
744
|
`,
|
|
717
745
|
},
|
|
718
746
|
]
|
package/src/schema/tables.ts
CHANGED
package/src/schema/types.ts
CHANGED
|
@@ -536,6 +536,37 @@ export interface ContentInsightDismissalRow {
|
|
|
536
536
|
dismissed_by: string | null
|
|
537
537
|
}
|
|
538
538
|
|
|
539
|
+
export type ContentPublicationOperation = 'publish' | 'rollback'
|
|
540
|
+
export type ContentPublicationAttemptState =
|
|
541
|
+
| 'preparing'
|
|
542
|
+
| 'prepared'
|
|
543
|
+
| 'committing'
|
|
544
|
+
| 'pending_retry'
|
|
545
|
+
| 'committed'
|
|
546
|
+
| 'superseded'
|
|
547
|
+
| 'aborting'
|
|
548
|
+
| 'aborted'
|
|
549
|
+
| 'abort_failed'
|
|
550
|
+
|
|
551
|
+
/** Durable publication outbox row. JSON columns are decoded by the publication engine. */
|
|
552
|
+
export interface ContentPublicationAttemptRow {
|
|
553
|
+
id: string
|
|
554
|
+
operation: ContentPublicationOperation
|
|
555
|
+
content_id: string
|
|
556
|
+
revision_id: string
|
|
557
|
+
previous_revision_id: string | null
|
|
558
|
+
state: ContentPublicationAttemptState
|
|
559
|
+
surface_names_json: string
|
|
560
|
+
pending_surface_names_json: string
|
|
561
|
+
prepared_surface_names_json: string
|
|
562
|
+
receipts_json: string
|
|
563
|
+
version: number
|
|
564
|
+
package_version: string | null
|
|
565
|
+
last_error: string | null
|
|
566
|
+
created_at: number
|
|
567
|
+
updated_at: number
|
|
568
|
+
}
|
|
569
|
+
|
|
539
570
|
/**
|
|
540
571
|
* Schema version for the intent_key derivation. Bump ONLY on a breaking change
|
|
541
572
|
* to the canonical-cluster-id semantics; bumping rotates every intent_key (and
|