@call-me-sensei/toonlab 0.4.19 → 0.4.21

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 (123) hide show
  1. package/NPM-LIBRARY.md +230 -0
  2. package/README.md +78 -36
  3. package/agents/PROMPTS.md +91 -0
  4. package/agents/README.md +9 -14
  5. package/agents/claude/CLAUDE.md +8 -0
  6. package/agents/codex/AGENTS.md +161 -0
  7. package/agents/cursor/toonlab.mdc +37 -0
  8. package/agents/references/mcp-asset-discovery.md +12 -9
  9. package/agents/references/runtime-entry-points.md +5 -4
  10. package/agents/skills/claude/asset-sourcing/SKILL.md +9 -6
  11. package/agents/skills/claude/rock-ground-shaders/SKILL.md +16 -0
  12. package/agents/skills/claude/vegetation-sky/SKILL.md +7 -4
  13. package/agents/skills/codex/asset-sourcing/SKILL.md +9 -6
  14. package/agents/skills/codex/rock-ground-shaders/SKILL.md +16 -0
  15. package/agents/skills/codex/vegetation-sky/SKILL.md +7 -4
  16. package/cli/toonlab.mjs +3 -2
  17. package/database/fal-patina.mjs +131 -0
  18. package/database/generation-service.mjs +108 -7
  19. package/database/migrations/0007_creation_revisions.sql +80 -0
  20. package/database/migrations/0008_creation_revision_hardening.sql +98 -0
  21. package/database/migrations/0009_creation_revision_delete_integrity.sql +11 -0
  22. package/database/migrations/0010_external_catalog_assets.sql +28 -0
  23. package/database/providers.mjs +195 -16
  24. package/database/repository.mjs +518 -70
  25. package/database/seeds/catalog/0003_2026-08-open-assets.sql +159086 -0
  26. package/database/seeds/catalog/0004_2026-08-c7-rocks.sql +39405 -0
  27. package/mcp/public-catalog.mjs +4 -52
  28. package/mcp/server.mjs +175 -12
  29. package/mcp/vite-plugin.mjs +78 -2
  30. package/package.json +32 -12
  31. package/scripts/generate-catalog-seed.mjs +34 -7
  32. package/src/asset-policy/catalogLicenses.js +64 -6
  33. package/src/assetlib/loadImported.js +15 -1
  34. package/src/catalog/officialCatalogAssetRuntime.js +13 -2
  35. package/src/catalog/officialCatalogLod.js +34 -1
  36. package/src/catalog/officialCatalogPlacement.js +29 -14
  37. package/src/character/animationRetarget.js +2 -0
  38. package/src/character/characterRig.js +6 -1
  39. package/src/character/characterRuntime.js +175 -7
  40. package/src/environment/environmentMaterialAdapter.js +30 -0
  41. package/src/environment/environmentPresets.js +90 -0
  42. package/src/environment/environmentSunShadowPass.js +42 -0
  43. package/src/environment/toonLabSurfaceLighting.js +108 -10
  44. package/src/environment/urbanPropMaterial.js +104 -2
  45. package/src/lighting/lightingSystem.js +65 -10
  46. package/src/renderer/index.js +7 -0
  47. package/src/renderer/styleComparison.js +985 -0
  48. package/src/rock-shader/index.js +5 -0
  49. package/src/rock-shader/rockGeometryDetail.js +701 -0
  50. package/src/rock-shader/rockMaterial.js +782 -39
  51. package/src/rock-shader/rockRegionRuntime.js +185 -0
  52. package/src/rock-shader/rockSemanticMaterialRuntime.js +337 -0
  53. package/src/rock-shader/rockShaderRuntime.js +354 -67
  54. package/src/rock-shader/rockShaderSettings.js +246 -16
  55. package/src/rock-shader/rockTangentIntegrity.js +136 -0
  56. package/src/rock-shader/rockTextureIntegrity.js +115 -0
  57. package/src/rockgen/index.js +1 -0
  58. package/src/rockgen/rockDocument.js +27 -3
  59. package/src/rockgen/surface/c7GeologySurface.js +372 -0
  60. package/src/shaders-tsl/water.js +8 -0
  61. package/src/sky/cloudShadow.js +25 -1
  62. package/src/sky/skySystem.js +9 -0
  63. package/src/styles/index.js +23 -0
  64. package/src/styles/neutralStylePresets.js +712 -0
  65. package/src/styles/sceneStyleRuntime.js +51 -4
  66. package/src/styles/styleAdapters.js +13 -2
  67. package/src/styles/styleBundle.js +1 -1
  68. package/src/texgen/evaluateTexture.js +30 -3
  69. package/src/texgen/textureGenerators.js +63 -31
  70. package/src/texgen/textureSettings.js +25 -8
  71. package/src/toon/toonSettings.js +96 -2
  72. package/src/vegetation/branchTree.js +327 -42
  73. package/src/vegetation/index.js +24 -2
  74. package/src/vegetation/scatter.js +372 -1
  75. package/src/vegetation/stylizedTree.js +312 -126
  76. package/src/vegetation/stylizedTreeFoliage.js +229 -7
  77. package/src/vegetation/treeSurfaceTextures.js +328 -0
  78. package/src/version.js +1 -1
  79. package/src/water/waterSettings.js +15 -7
  80. package/types/asset-policy/catalogLicenses.d.ts +2 -0
  81. package/types/asset-policy/index.d.ts +2 -0
  82. package/types/assetlib/loadImported.d.ts +3 -1
  83. package/types/catalog/officialCatalogLod.d.ts +63 -5
  84. package/types/character/characterRig.d.ts +59 -0
  85. package/types/character/characterRuntime.d.ts +1 -0
  86. package/types/environment/environmentMaterialAdapter.d.ts +3 -1
  87. package/types/environment/toonLabSurfaceLighting.d.ts +20 -3
  88. package/types/index.d.ts +89 -7
  89. package/types/lighting/lightingSystem.d.ts +7 -0
  90. package/types/react/index.d.ts +20 -0
  91. package/types/renderer/index.d.ts +1 -0
  92. package/types/renderer/styleComparison.d.ts +409 -0
  93. package/types/rock-shader/index.d.ts +5 -0
  94. package/types/rock-shader/rockGeometryDetail.d.ts +300 -0
  95. package/types/rock-shader/rockMaterial.d.ts +3 -0
  96. package/types/rock-shader/rockRegionRuntime.d.ts +86 -0
  97. package/types/rock-shader/rockSemanticMaterialRuntime.d.ts +29 -0
  98. package/types/rock-shader/rockShaderRuntime.d.ts +2 -0
  99. package/types/rock-shader/rockShaderSettings.d.ts +19 -5
  100. package/types/rock-shader/rockTangentIntegrity.d.ts +51 -0
  101. package/types/rock-shader/rockTextureIntegrity.d.ts +61 -0
  102. package/types/rockgen/index.d.ts +1 -0
  103. package/types/rockgen/rockDocument.d.ts +29 -1
  104. package/types/rockgen/surface/c7GeologySurface.d.ts +8 -0
  105. package/types/sky/cloudShadow.d.ts +1 -0
  106. package/types/styles/index.d.ts +1 -0
  107. package/types/styles/neutralStylePresets.d.ts +944 -0
  108. package/types/styles/sceneStyleRuntime.d.ts +20 -0
  109. package/types/vegetation/branchTree.d.ts +4 -0
  110. package/types/vegetation/index.d.ts +9 -7
  111. package/types/vegetation/scatter.d.ts +1 -0
  112. package/types/vegetation/stylizedTree.d.ts +1 -2
  113. package/types/vegetation/stylizedTreeFoliage.d.ts +1 -0
  114. package/types/version.d.ts +1 -1
  115. package/agents/references/geology-playbook.md +0 -118
  116. package/agents/skills/claude/karst-cliff-construction/SKILL.md +0 -258
  117. package/agents/skills/claude/outdoor-world/SKILL.md +0 -492
  118. package/agents/skills/claude/outdoor-world/references/coastal-landform-formation.md +0 -100
  119. package/agents/skills/codex/karst-cliff-construction/SKILL.md +0 -258
  120. package/agents/skills/codex/outdoor-world/SKILL.md +0 -492
  121. package/agents/skills/codex/outdoor-world/references/coastal-landform-formation.md +0 -100
  122. package/src/vegetation/legacyTreePresets.js +0 -120
  123. package/types/vegetation/legacyTreePresets.d.ts +0 -22
@@ -15,10 +15,12 @@ import {
15
15
  pollTripoTask,
16
16
  runProvider,
17
17
  } from './providers.mjs';
18
+ import { PATINA_MAP_TYPES } from './fal-patina.mjs';
18
19
 
19
20
  const IMAGE_KINDS = new Set(['image', 'texture_image', 'concept_image']);
20
21
  const MODEL_KINDS = new Set(['text_to_model', 'image_to_model', 'multiview_to_model', 'model_segment']);
21
- const RESOLUTIONS = new Set(['1k', '2k', '4k']);
22
+ const MATERIAL_KINDS = new Set(['material']);
23
+ const RESOLUTIONS = new Set(['1k', '2k', '4k', '8k']);
22
24
  const ASPECT_RATIOS = new Set(['1:1', '16:9', '9:16', '4:3', '3:4']);
23
25
 
24
26
  function publicFile(file) {
@@ -42,6 +44,17 @@ export function publicGenerationJob(job) {
42
44
  ...(job.result.file ? { file: publicFile(job.result.file) } : {}),
43
45
  ...(job.result.previewFile ? { previewFile: publicFile(job.result.previewFile) } : {}),
44
46
  ...(job.result.conceptFile ? { conceptFile: publicFile(job.result.conceptFile) } : {}),
47
+ ...(job.result.materialBundle ? {
48
+ materialBundle: {
49
+ ...job.result.materialBundle,
50
+ ...(job.result.materialBundle.manifestFile ? { manifestFile: publicFile(job.result.materialBundle.manifestFile) } : {}),
51
+ variants: (job.result.materialBundle.variants ?? []).map((variant) => ({
52
+ ...variant,
53
+ preview: publicFile(variant.preview),
54
+ maps: Object.fromEntries(Object.entries(variant.maps ?? {}).map(([key, file]) => [key, publicFile(file)])),
55
+ })),
56
+ },
57
+ } : {}),
45
58
  }
46
59
  : job.result;
47
60
  return {
@@ -58,11 +71,13 @@ function configuredImageModel(id, configuration) {
58
71
  /** Pure request planner shared by MCP validation and regression tests. */
59
72
  export function resolveGenerationPlan(args, configuration = providerConfiguration()) {
60
73
  const kind = String(args.kind ?? '');
61
- if (!IMAGE_KINDS.has(kind) && !MODEL_KINDS.has(kind)) {
74
+ if (!IMAGE_KINDS.has(kind) && !MODEL_KINDS.has(kind) && !MATERIAL_KINDS.has(kind)) {
62
75
  throw new Error(`Unsupported generation kind "${kind}".`);
63
76
  }
64
77
  const resolution = String(args.resolution ?? '1k').toLowerCase();
65
- if (!RESOLUTIONS.has(resolution)) throw new Error(`Unsupported resolution "${resolution}".`);
78
+ if (!RESOLUTIONS.has(resolution) || (resolution === '8k' && !MATERIAL_KINDS.has(kind))) {
79
+ throw new Error(`Unsupported resolution "${resolution}".`);
80
+ }
66
81
  const aspectRatio = String(args.aspect_ratio ?? '1:1');
67
82
  if (!ASPECT_RATIOS.has(aspectRatio)) throw new Error(`Unsupported aspect ratio "${aspectRatio}".`);
68
83
  const modelProvider = String(args.model_provider ?? 'meshy');
@@ -91,7 +106,7 @@ export function resolveGenerationPlan(args, configuration = providerConfiguratio
91
106
  if (kind === 'model_segment' && !args.source_job_id) {
92
107
  throw new Error('model_segment requires source_job_id.');
93
108
  }
94
- const provider = IMAGE_KINDS.has(kind) ? imageModel.provider : modelProvider;
109
+ const provider = MATERIAL_KINDS.has(kind) ? 'fal' : IMAGE_KINDS.has(kind) ? imageModel.provider : modelProvider;
95
110
  if (!configuration.providers[provider]) throw new Error(`${provider} is not configured.`);
96
111
  if (textViaImage && !configuration.providers[imageModel.provider]) {
97
112
  throw new Error(`${imageModel.provider} is not configured for the selected image model.`);
@@ -106,6 +121,70 @@ export function resolveGenerationPlan(args, configuration = providerConfiguratio
106
121
  };
107
122
  }
108
123
 
124
+ function extensionFor(contentType, fallbackName = '') {
125
+ if (contentType === 'image/jpeg') return 'jpg';
126
+ if (contentType === 'image/webp') return 'webp';
127
+ if (contentType === 'image/png') return 'png';
128
+ const extension = String(fallbackName).split('.').pop()?.toLowerCase();
129
+ return ['jpg', 'jpeg', 'png', 'webp'].includes(extension) ? extension : 'png';
130
+ }
131
+
132
+ /** Download every ephemeral Fal output and persist one grouped material bundle locally. */
133
+ export async function storeManagedMaterial(workspacePath, jobId, providerResult) {
134
+ const material = providerResult?.material;
135
+ if (!material || !Array.isArray(material.images)) throw new Error('Fal Patina returned no material bundle.');
136
+ const stored = [];
137
+ for (let index = 0; index < material.images.length; index += 1) {
138
+ const remote = material.images[index];
139
+ const downloaded = await downloadProviderAsset(remote.url, { maxBytes: 256 * 1024 * 1024 });
140
+ if (!downloaded.contentType.startsWith('image/')) throw new Error('Fal Patina returned a non-image material file.');
141
+ const role = remote.mapType ?? 'preview';
142
+ const file = publicFile(await saveObject(workspacePath, downloaded.bytes, {
143
+ contentType: downloaded.contentType,
144
+ kind: remote.mapType ? 'generated-material-map' : 'generated-material-preview',
145
+ name: `${role}-${index + 1}.${extensionFor(downloaded.contentType, downloaded.name)}`,
146
+ }));
147
+ stored.push({ file, mapType: remote.mapType });
148
+ }
149
+ const previews = stored.filter((entry) => entry.mapType === null).map((entry) => entry.file);
150
+ const variantsByMap = Object.fromEntries(PATINA_MAP_TYPES.map((map) => [
151
+ map,
152
+ stored.filter((entry) => entry.mapType === map).map((entry) => entry.file),
153
+ ]));
154
+ const variantCount = Math.max(previews.length, ...Object.values(variantsByMap).map((files) => files.length), 1);
155
+ const variants = Array.from({ length: variantCount }, (_, outputIndex) => ({
156
+ outputIndex,
157
+ preview: previews[outputIndex] ?? null,
158
+ maps: Object.fromEntries(PATINA_MAP_TYPES.flatMap((map) => (
159
+ variantsByMap[map][outputIndex] ? [[map, variantsByMap[map][outputIndex]]] : []
160
+ ))),
161
+ }));
162
+ const manifest = {
163
+ schema: 'toonlab-pbr-material-v1',
164
+ generationJobId: jobId,
165
+ model: 'fal-ai/patina/material',
166
+ provider: 'fal',
167
+ request: material.input,
168
+ generatedPrompt: material.prompt,
169
+ seed: material.seed,
170
+ timings: material.timings,
171
+ variants,
172
+ };
173
+ const manifestFile = publicFile(await saveObject(workspacePath, new TextEncoder().encode(JSON.stringify(manifest, null, 2)), {
174
+ contentType: 'application/json',
175
+ kind: 'generated-material-manifest',
176
+ name: `material-${jobId}.json`,
177
+ }));
178
+ const primary = variants[0]?.maps?.basecolor ?? variants[0]?.preview ?? Object.values(variants[0]?.maps ?? {})[0];
179
+ if (!primary) throw new Error('Fal Patina material bundle has no usable output.');
180
+ return {
181
+ file: primary,
182
+ previewFile: variants[0]?.preview ?? primary,
183
+ materialBundle: { ...manifest, manifestFile },
184
+ provider: 'fal',
185
+ };
186
+ }
187
+
109
188
  async function referenceImage(workspacePath, relativePath) {
110
189
  const file = await readObject(workspacePath, relativePath);
111
190
  if (!String(file.mimeType).startsWith('image/')) throw new Error('Generation references must be images.');
@@ -143,6 +222,23 @@ export async function startManagedGeneration(workspacePath, args) {
143
222
  style: args.style ?? 'stylized',
144
223
  textViaImage: plan.textViaImage,
145
224
  views: Array.isArray(args.view_paths) ? args.view_paths.slice(0, 4) : [],
225
+ ...(plan.kind === 'material' ? {
226
+ enablePromptExpansion: args.enable_prompt_expansion,
227
+ enableSafetyChecker: args.enable_safety_checker,
228
+ imageSize: args.image_size,
229
+ imageUrl: args.image_url,
230
+ maps: args.maps,
231
+ maskUrl: args.mask_url,
232
+ numImages: args.num_images,
233
+ numInferenceSteps: args.num_inference_steps,
234
+ outputFormat: args.output_format,
235
+ seed: args.seed,
236
+ strength: args.strength,
237
+ tileSize: args.tile_size,
238
+ tileStride: args.tile_stride,
239
+ tilingMode: args.tiling_mode,
240
+ upscaleFactor: args.upscale_factor,
241
+ } : {}),
146
242
  };
147
243
  const job = await createGenerationJob(plan.provider, plan.kind, request);
148
244
  try {
@@ -182,6 +278,7 @@ export async function startManagedGeneration(workspacePath, args) {
182
278
  });
183
279
  } else {
184
280
  providerResult = await runProvider(plan.provider, {
281
+ ...(plan.kind === 'material' ? request : {}),
185
282
  aspectRatio: plan.aspectRatio,
186
283
  kind: plan.kind,
187
284
  model: plan.imageModel?.model,
@@ -200,6 +297,10 @@ export async function startManagedGeneration(workspacePath, args) {
200
297
  ...(conceptFile ? { conceptFile, pipeline: 'concept_image -> meshy-7' } : {}),
201
298
  }));
202
299
  }
300
+ if (providerResult.material) {
301
+ const result = await storeManagedMaterial(workspacePath, job.id, providerResult);
302
+ return publicGenerationJob(await finishGenerationJob(job.id, { result }));
303
+ }
203
304
  if (providerResult.bytes instanceof Uint8Array) {
204
305
  const file = await storeInlineImage(workspacePath, job.id, providerResult);
205
306
  return publicGenerationJob(await finishGenerationJob(job.id, {
@@ -296,8 +397,8 @@ export async function saveManagedGeneration(id, { label } = {}) {
296
397
  const job = await getGenerationJob(id);
297
398
  if (!job) throw new Error('Generation job not found.');
298
399
  if (job.status !== 'succeeded') throw new Error(`Generation job is ${job.status}, not succeeded.`);
299
- if (!['text_to_model', 'image_to_model', 'multiview_to_model'].includes(job.kind) || !job.result?.file) {
300
- throw new Error('Only succeeded 3D model jobs can be saved as prop assets.');
400
+ if (!['text_to_model', 'image_to_model', 'multiview_to_model', 'material'].includes(job.kind) || !job.result?.file) {
401
+ throw new Error('Only succeeded 3D model or material jobs can be saved.');
301
402
  }
302
403
  const entry = {
303
404
  aiGenerated: true,
@@ -308,7 +409,7 @@ export async function saveManagedGeneration(id, { label } = {}) {
308
409
  provider: job.provider,
309
410
  result: job.result,
310
411
  source: 'generate',
311
- type: 'generated-model',
412
+ type: job.kind === 'material' ? 'generated-material' : 'generated-model',
312
413
  };
313
414
  return saveLibraryEntry(entry);
314
415
  }
@@ -0,0 +1,80 @@
1
+ create table if not exists creation_revisions (
2
+ id uuid primary key default gen_random_uuid(),
3
+ creation_id uuid not null references creations(id) on delete cascade,
4
+ revision_number integer not null check (revision_number > 0),
5
+ label text not null,
6
+ description text,
7
+ tags text[] not null default '{}',
8
+ document jsonb not null,
9
+ ai_generated boolean not null default false,
10
+ content_hash text not null,
11
+ save_source text not null default 'manual',
12
+ dependency_snapshot jsonb not null default '{}',
13
+ restored_from_revision_id uuid references creation_revisions(id) on delete set null,
14
+ version_name text,
15
+ version_tags text[] not null default '{}',
16
+ version_note text,
17
+ pinned boolean not null default false,
18
+ annotation_updated_at timestamptz,
19
+ created_at timestamptz not null default now(),
20
+ unique (creation_id, revision_number),
21
+ check (version_name is null or char_length(version_name) between 1 and 120),
22
+ check (version_note is null or char_length(version_note) <= 2000),
23
+ check (cardinality(version_tags) <= 10)
24
+ );
25
+
26
+ create index if not exists creation_revisions_creation_created
27
+ on creation_revisions (creation_id, revision_number desc);
28
+ create index if not exists creation_revisions_pinned
29
+ on creation_revisions (creation_id, pinned, revision_number desc);
30
+ create unique index if not exists creation_revisions_unique_name
31
+ on creation_revisions (creation_id, lower(version_name))
32
+ where version_name is not null;
33
+
34
+ alter table creations
35
+ add column if not exists current_revision_id uuid references creation_revisions(id) on delete set null;
36
+
37
+ with inserted as (
38
+ insert into creation_revisions (
39
+ creation_id, revision_number, label, description, tags, document,
40
+ ai_generated, content_hash, save_source
41
+ )
42
+ select
43
+ c.id,
44
+ 1,
45
+ c.label,
46
+ c.description,
47
+ c.tags,
48
+ c.document,
49
+ c.ai_generated,
50
+ encode(digest(convert_to(jsonb_build_object(
51
+ 'label', c.label,
52
+ 'description', c.description,
53
+ 'tags', c.tags,
54
+ 'document', c.document,
55
+ 'aiGenerated', c.ai_generated,
56
+ 'dependencies', '{}'::jsonb
57
+ )::text, 'UTF8'), 'sha256'), 'hex'),
58
+ 'migration'
59
+ from creations c
60
+ where c.current_revision_id is null
61
+ and not exists (
62
+ select 1 from creation_revisions r where r.creation_id = c.id
63
+ )
64
+ returning id, creation_id
65
+ )
66
+ update creations c
67
+ set current_revision_id = inserted.id
68
+ from inserted
69
+ where c.id = inserted.creation_id;
70
+
71
+ update creations c
72
+ set current_revision_id = (
73
+ select r.id
74
+ from creation_revisions r
75
+ where r.creation_id = c.id
76
+ order by r.revision_number desc
77
+ limit 1
78
+ )
79
+ where c.current_revision_id is null
80
+ and exists (select 1 from creation_revisions r where r.creation_id = c.id);
@@ -0,0 +1,98 @@
1
+ -- Repair creations imported after 0007 ran but before the import path began
2
+ -- committing its initial snapshot.
3
+ with inserted as (
4
+ insert into creation_revisions (
5
+ creation_id, revision_number, label, description, tags, document,
6
+ ai_generated, content_hash, save_source
7
+ )
8
+ select
9
+ c.id,
10
+ 1,
11
+ c.label,
12
+ c.description,
13
+ c.tags,
14
+ c.document,
15
+ c.ai_generated,
16
+ encode(digest(convert_to(jsonb_build_object(
17
+ 'label', c.label,
18
+ 'description', c.description,
19
+ 'tags', c.tags,
20
+ 'document', c.document,
21
+ 'aiGenerated', c.ai_generated,
22
+ 'dependencies', '{}'::jsonb
23
+ )::text, 'UTF8'), 'sha256'), 'hex'),
24
+ 'hardening-backfill'
25
+ from creations c
26
+ where c.current_revision_id is null
27
+ and not exists (select 1 from creation_revisions r where r.creation_id = c.id)
28
+ returning id, creation_id
29
+ )
30
+ update creations c
31
+ set current_revision_id = inserted.id
32
+ from inserted
33
+ where c.id = inserted.creation_id;
34
+
35
+ create or replace function toonlab_guard_creation_revision_snapshot()
36
+ returns trigger
37
+ language plpgsql
38
+ as $$
39
+ begin
40
+ if new.creation_id is distinct from old.creation_id
41
+ or new.revision_number is distinct from old.revision_number
42
+ or new.label is distinct from old.label
43
+ or new.description is distinct from old.description
44
+ or new.tags is distinct from old.tags
45
+ or new.document is distinct from old.document
46
+ or new.ai_generated is distinct from old.ai_generated
47
+ or new.content_hash is distinct from old.content_hash
48
+ or new.save_source is distinct from old.save_source
49
+ or new.dependency_snapshot is distinct from old.dependency_snapshot
50
+ or new.restored_from_revision_id is distinct from old.restored_from_revision_id
51
+ or new.created_at is distinct from old.created_at then
52
+ raise exception 'creation revision snapshots are immutable';
53
+ end if;
54
+ return new;
55
+ end;
56
+ $$;
57
+
58
+ create trigger creation_revisions_immutable_snapshot
59
+ before update on creation_revisions
60
+ for each row execute function toonlab_guard_creation_revision_snapshot();
61
+
62
+ create or replace function toonlab_guard_creation_revision_links()
63
+ returns trigger
64
+ language plpgsql
65
+ as $$
66
+ begin
67
+ if new.restored_from_revision_id is not null and not exists (
68
+ select 1 from creation_revisions r
69
+ where r.id = new.restored_from_revision_id and r.creation_id = new.creation_id
70
+ ) then
71
+ raise exception 'restored revision must belong to the same creation';
72
+ end if;
73
+ return new;
74
+ end;
75
+ $$;
76
+
77
+ create trigger creation_revisions_same_creation_restore
78
+ before insert or update of restored_from_revision_id, creation_id on creation_revisions
79
+ for each row execute function toonlab_guard_creation_revision_links();
80
+
81
+ create or replace function toonlab_guard_current_creation_revision()
82
+ returns trigger
83
+ language plpgsql
84
+ as $$
85
+ begin
86
+ if new.current_revision_id is not null and not exists (
87
+ select 1 from creation_revisions r
88
+ where r.id = new.current_revision_id and r.creation_id = new.id
89
+ ) then
90
+ raise exception 'current revision must belong to the same creation';
91
+ end if;
92
+ return new;
93
+ end;
94
+ $$;
95
+
96
+ create trigger creations_same_current_revision
97
+ before insert or update of current_revision_id, id on creations
98
+ for each row execute function toonlab_guard_current_creation_revision();
@@ -0,0 +1,11 @@
1
+ -- A revision snapshot must never be rewritten merely because its source
2
+ -- revision is deleted. NO ACTION also permits a whole creation (and all of
3
+ -- its mutually-related revisions) to be removed by the parent cascade.
4
+ alter table creation_revisions
5
+ drop constraint creation_revisions_restored_from_revision_id_fkey;
6
+
7
+ alter table creation_revisions
8
+ add constraint creation_revisions_restored_from_revision_id_fkey
9
+ foreign key (restored_from_revision_id)
10
+ references creation_revisions(id)
11
+ on delete no action;
@@ -0,0 +1,28 @@
1
+ -- Catalog-only entries intentionally retain an upstream or independently
2
+ -- mirrored public URL. They have no release-owned byte payload, so storing a
3
+ -- fabricated SHA-256, byte count, or content type would be misleading.
4
+ alter table catalog_assets
5
+ alter column sha256 drop not null,
6
+ alter column byte_size drop not null,
7
+ alter column content_type drop not null;
8
+
9
+ alter table catalog_assets
10
+ drop constraint if exists catalog_assets_integrity_matches_scope;
11
+
12
+ alter table catalog_assets
13
+ add constraint catalog_assets_integrity_matches_scope
14
+ check (
15
+ (
16
+ redistribution_scope = 'external-only'
17
+ and sha256 is null
18
+ and byte_size is null
19
+ and content_type is null
20
+ )
21
+ or
22
+ (
23
+ redistribution_scope <> 'external-only'
24
+ and sha256 is not null
25
+ and byte_size is not null
26
+ and content_type is not null
27
+ )
28
+ );