@jini-ai/cms 0.5.1 → 0.5.3

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 (209) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +14 -8
  3. package/dist/comments/errors.d.ts +23 -0
  4. package/dist/comments/errors.js +25 -0
  5. package/dist/comments/express/index.d.ts +2 -0
  6. package/dist/comments/express/index.js +2 -0
  7. package/dist/comments/express/submit-handler.d.ts +30 -0
  8. package/dist/comments/express/submit-handler.js +85 -0
  9. package/dist/comments/hooks.d.ts +39 -0
  10. package/dist/comments/hooks.js +41 -0
  11. package/dist/comments/index.d.ts +11 -0
  12. package/dist/comments/index.js +11 -0
  13. package/dist/comments/ingress.d.ts +57 -0
  14. package/dist/comments/ingress.js +173 -0
  15. package/dist/comments/module.d.ts +94 -0
  16. package/dist/comments/module.js +73 -0
  17. package/dist/comments/ports.d.ts +184 -0
  18. package/dist/comments/ports.js +2 -0
  19. package/dist/comments/repo.memory.d.ts +70 -0
  20. package/dist/comments/repo.memory.js +120 -0
  21. package/dist/comments/sanitize.d.ts +15 -0
  22. package/dist/comments/sanitize.js +31 -0
  23. package/dist/comments/spam.heuristic.d.ts +8 -0
  24. package/dist/comments/spam.heuristic.js +54 -0
  25. package/dist/comments/sql/index.d.ts +3 -0
  26. package/dist/comments/sql/index.js +3 -0
  27. package/dist/comments/sql/repo.d.ts +77 -0
  28. package/dist/comments/sql/repo.js +190 -0
  29. package/dist/comments/sql/repo.rows.d.ts +49 -0
  30. package/dist/comments/sql/repo.rows.js +71 -0
  31. package/dist/comments/types.d.ts +187 -0
  32. package/dist/comments/types.js +39 -0
  33. package/dist/comments/write-service.d.ts +121 -0
  34. package/dist/comments/write-service.js +144 -0
  35. package/dist/core/commands/command.d.ts +2 -9
  36. package/dist/core/commands/command.js +2 -9
  37. package/dist/core/index.d.ts +2 -1
  38. package/dist/core/index.js +1 -0
  39. package/dist/core/keyset-cursor.d.ts +25 -0
  40. package/dist/core/keyset-cursor.js +20 -0
  41. package/dist/core/ports.d.ts +30 -0
  42. package/dist/core/ports.js +0 -1
  43. package/dist/core/tools/args.d.ts +1 -46
  44. package/dist/core/tools/args.js +1 -44
  45. package/dist/media/index.d.ts +1 -2
  46. package/dist/media/index.js +2 -2
  47. package/dist/media/node.d.ts +5 -0
  48. package/dist/media/node.js +6 -0
  49. package/dist/media/video-frames.d.ts +94 -0
  50. package/dist/media/video-frames.ffmpeg.d.ts +39 -0
  51. package/dist/media/video-frames.ffmpeg.js +261 -0
  52. package/dist/media/video-frames.js +97 -0
  53. package/dist/redirects/capture.d.ts +80 -0
  54. package/dist/redirects/capture.js +66 -0
  55. package/dist/redirects/express/index.d.ts +27 -0
  56. package/dist/redirects/express/index.js +40 -0
  57. package/dist/redirects/hit-sink.d.ts +56 -0
  58. package/dist/redirects/hit-sink.js +44 -0
  59. package/dist/redirects/host-config/index.d.ts +40 -0
  60. package/dist/redirects/host-config/index.js +145 -0
  61. package/dist/redirects/index.d.ts +14 -0
  62. package/dist/redirects/index.js +14 -0
  63. package/dist/redirects/matcher.d.ts +58 -0
  64. package/dist/redirects/matcher.js +144 -0
  65. package/dist/redirects/order.d.ts +30 -0
  66. package/dist/redirects/order.js +26 -0
  67. package/dist/redirects/ports.d.ts +242 -0
  68. package/dist/redirects/ports.internal.d.ts +57 -0
  69. package/dist/redirects/ports.internal.js +14 -0
  70. package/dist/redirects/ports.js +2 -0
  71. package/dist/redirects/redirects.d.ts +226 -0
  72. package/dist/redirects/redirects.js +590 -0
  73. package/dist/redirects/referrer-alias.d.ts +31 -0
  74. package/dist/redirects/referrer-alias.js +33 -0
  75. package/dist/redirects/repo.memory.d.ts +51 -0
  76. package/dist/redirects/repo.memory.js +98 -0
  77. package/dist/redirects/reserved-destination.d.ts +58 -0
  78. package/dist/redirects/reserved-destination.js +59 -0
  79. package/dist/redirects/resolver.d.ts +100 -0
  80. package/dist/redirects/resolver.js +87 -0
  81. package/dist/redirects/spa/index.d.ts +30 -0
  82. package/dist/redirects/spa/index.js +27 -0
  83. package/dist/redirects/sql/index.d.ts +4 -0
  84. package/dist/redirects/sql/index.js +4 -0
  85. package/dist/redirects/sql/repo.d.ts +82 -0
  86. package/dist/redirects/sql/repo.js +143 -0
  87. package/dist/redirects/sql/repo.rows.d.ts +49 -0
  88. package/dist/redirects/sql/repo.rows.js +79 -0
  89. package/dist/redirects/sql/row-types.d.ts +34 -0
  90. package/dist/redirects/sql/row-types.js +2 -0
  91. package/dist/redirects/target-gate.d.ts +30 -0
  92. package/dist/redirects/target-gate.js +85 -0
  93. package/dist/redirects/types.d.ts +213 -0
  94. package/dist/redirects/types.js +42 -0
  95. package/dist/seo/absolute-url.d.ts +58 -0
  96. package/dist/seo/absolute-url.js +117 -0
  97. package/dist/seo/dom/index.d.ts +14 -0
  98. package/dist/seo/dom/index.js +52 -0
  99. package/dist/seo/errors.d.ts +35 -0
  100. package/dist/seo/errors.js +35 -0
  101. package/dist/seo/feed.d.ts +49 -0
  102. package/dist/seo/feed.js +61 -0
  103. package/dist/seo/index.d.ts +13 -0
  104. package/dist/seo/index.js +13 -0
  105. package/dist/seo/media.d.ts +57 -0
  106. package/dist/seo/media.js +134 -0
  107. package/dist/seo/page-head.d.ts +18 -0
  108. package/dist/seo/page-head.js +114 -0
  109. package/dist/seo/ports.d.ts +221 -0
  110. package/dist/seo/ports.js +27 -0
  111. package/dist/seo/seo.d.ts +73 -0
  112. package/dist/seo/seo.js +230 -0
  113. package/dist/seo/sitemap.d.ts +139 -0
  114. package/dist/seo/sitemap.js +217 -0
  115. package/dist/seo/types.d.ts +278 -0
  116. package/dist/seo/types.js +2 -0
  117. package/dist/seo/write-service.d.ts +79 -0
  118. package/dist/seo/write-service.js +273 -0
  119. package/dist/seo/xml-escape.d.ts +16 -0
  120. package/dist/seo/xml-escape.js +19 -0
  121. package/dist/settings/express/cms-adapter.d.ts +20 -0
  122. package/dist/settings/express/cms-adapter.js +124 -0
  123. package/dist/settings/express/contracts.d.ts +135 -0
  124. package/dist/settings/express/contracts.js +2 -0
  125. package/dist/settings/express/http.d.ts +10 -0
  126. package/dist/settings/express/http.js +211 -0
  127. package/dist/settings/express/index.d.ts +5 -0
  128. package/dist/settings/express/index.js +5 -0
  129. package/dist/settings/express/shared.d.ts +68 -0
  130. package/dist/settings/express/shared.js +54 -0
  131. package/dist/settings/express/stream.d.ts +24 -0
  132. package/dist/settings/express/stream.js +147 -0
  133. package/dist/settings/index.d.ts +3 -38
  134. package/dist/settings/index.js +3 -35
  135. package/dist/taxonomy/write-service.d.ts +2 -30
  136. package/dist/widgets/authorize-helper.d.ts +42 -0
  137. package/dist/widgets/authorize-helper.js +16 -0
  138. package/dist/widgets/concurrency.d.ts +12 -0
  139. package/dist/widgets/concurrency.js +44 -0
  140. package/dist/widgets/config-validation.d.ts +34 -0
  141. package/dist/widgets/config-validation.js +125 -0
  142. package/dist/widgets/embed-service.d.ts +133 -0
  143. package/dist/widgets/embed-service.js +460 -0
  144. package/dist/widgets/embed-validation.d.ts +23 -0
  145. package/dist/widgets/embed-validation.js +38 -0
  146. package/dist/widgets/entry-payload.d.ts +103 -0
  147. package/dist/widgets/entry-payload.js +160 -0
  148. package/dist/widgets/errors.d.ts +136 -0
  149. package/dist/widgets/errors.js +136 -0
  150. package/dist/widgets/host-ports.d.ts +149 -0
  151. package/dist/widgets/host-ports.js +2 -0
  152. package/dist/widgets/html/html-embeds.d.ts +247 -0
  153. package/dist/widgets/html/html-embeds.js +408 -0
  154. package/dist/widgets/html/index.d.ts +5 -0
  155. package/dist/widgets/html/index.js +4 -0
  156. package/dist/widgets/html/legacy-widget-markers.d.ts +8 -0
  157. package/dist/widgets/html/legacy-widget-markers.js +45 -0
  158. package/dist/widgets/html/resolve-html-page-embeds.d.ts +42 -0
  159. package/dist/widgets/html/resolve-html-page-embeds.js +57 -0
  160. package/dist/widgets/index.d.ts +20 -0
  161. package/dist/widgets/index.js +19 -0
  162. package/dist/widgets/markers/html-attributes.d.ts +98 -0
  163. package/dist/widgets/markers/html-attributes.js +216 -0
  164. package/dist/widgets/markers/index.d.ts +3 -0
  165. package/dist/widgets/markers/index.js +3 -0
  166. package/dist/widgets/markers/marker.d.ts +496 -0
  167. package/dist/widgets/markers/marker.js +780 -0
  168. package/dist/widgets/page-embed-types.d.ts +42 -0
  169. package/dist/widgets/page-embed-types.js +42 -0
  170. package/dist/widgets/ports.d.ts +142 -0
  171. package/dist/widgets/ports.js +2 -0
  172. package/dist/widgets/read-service.d.ts +81 -0
  173. package/dist/widgets/read-service.js +79 -0
  174. package/dist/widgets/region-area-service.d.ts +110 -0
  175. package/dist/widgets/region-area-service.js +180 -0
  176. package/dist/widgets/registry.d.ts +217 -0
  177. package/dist/widgets/registry.js +172 -0
  178. package/dist/widgets/repo.memory.d.ts +41 -0
  179. package/dist/widgets/repo.memory.js +48 -0
  180. package/dist/widgets/resolver-service.d.ts +209 -0
  181. package/dist/widgets/resolver-service.js +909 -0
  182. package/dist/widgets/resolvers/contact-form.d.ts +26 -0
  183. package/dist/widgets/resolvers/contact-form.js +54 -0
  184. package/dist/widgets/resolvers/create-core-resolvers.d.ts +34 -0
  185. package/dist/widgets/resolvers/create-core-resolvers.js +11 -0
  186. package/dist/widgets/resolvers/index.d.ts +65 -0
  187. package/dist/widgets/resolvers/index.js +155 -0
  188. package/dist/widgets/resolvers/menu.d.ts +42 -0
  189. package/dist/widgets/resolvers/menu.js +43 -0
  190. package/dist/widgets/resolvers/ports.d.ts +82 -0
  191. package/dist/widgets/resolvers/ports.js +2 -0
  192. package/dist/widgets/resolvers/recent-entries.d.ts +71 -0
  193. package/dist/widgets/resolvers/recent-entries.js +165 -0
  194. package/dist/widgets/resolvers/taxonomy.d.ts +12 -0
  195. package/dist/widgets/resolvers/taxonomy.js +34 -0
  196. package/dist/widgets/sql/index.d.ts +3 -0
  197. package/dist/widgets/sql/index.js +3 -0
  198. package/dist/widgets/sql/repo.d.ts +41 -0
  199. package/dist/widgets/sql/repo.js +82 -0
  200. package/dist/widgets/sql/tables.d.ts +13 -0
  201. package/dist/widgets/sql/tables.js +2 -0
  202. package/dist/widgets/types.d.ts +213 -0
  203. package/dist/widgets/types.js +17 -0
  204. package/dist/widgets/where-used.d.ts +41 -0
  205. package/dist/widgets/where-used.js +11 -0
  206. package/dist/widgets/write-service.d.ts +204 -0
  207. package/dist/widgets/write-service.js +332 -0
  208. package/dist/workspace/delete.d.ts +1 -1
  209. package/package.json +220 -11
@@ -0,0 +1,94 @@
1
+ /** Video preview contract and sampling policy, independent of a host, codec or AI provider. */
2
+ export declare const VIDEO_MAX_FRAMES = 64;
3
+ export declare const VIDEO_DEFAULT_FRAMES = 16;
4
+ export declare const VIDEO_MAX_EDGE_PX = 768;
5
+ export declare const VIDEO_MAX_INPUT_BYTES: number;
6
+ export declare const VIDEO_MAX_FRAME_BYTES: number;
7
+ /** Aggregate raw JPEG payload budget; base64 expands this to about 10.67 MiB plus metadata. */
8
+ export declare const VIDEO_MAX_OUTPUT_BYTES: number;
9
+ export declare const VIDEO_MAX_SHEET_BYTES: number;
10
+ export declare const VIDEO_MAX_SHEETS = 4;
11
+ export declare const VIDEO_EXTRACTION_TIMEOUT_MS = 60000;
12
+ export interface VideoMetadata {
13
+ readonly duration: number;
14
+ readonly width: number;
15
+ readonly height: number;
16
+ readonly codec: string;
17
+ readonly container: string;
18
+ readonly sizeBytes: number;
19
+ readonly hasAudio: boolean;
20
+ }
21
+ export interface VideoFrame {
22
+ readonly at: number;
23
+ readonly mimeType: 'image/jpeg';
24
+ readonly bytes: Uint8Array;
25
+ }
26
+ export interface VideoSheetTile {
27
+ readonly index: number;
28
+ readonly row: number;
29
+ readonly column: number;
30
+ readonly at: number;
31
+ }
32
+ export interface VideoSheetLayout {
33
+ readonly columns: number;
34
+ readonly rows: number;
35
+ readonly tileEdgePx: number;
36
+ /** One-based row-major positions; timestamp order and repetitions are preserved. */
37
+ readonly tiles: readonly VideoSheetTile[];
38
+ }
39
+ export interface VideoContactSheet extends VideoSheetLayout {
40
+ readonly mimeType: 'image/jpeg';
41
+ readonly bytes: Uint8Array;
42
+ }
43
+ export interface VideoFrameOptions {
44
+ readonly layout?: 'frames' | 'sheet';
45
+ readonly frames?: number;
46
+ readonly at?: readonly number[];
47
+ readonly every?: number;
48
+ readonly start?: number;
49
+ readonly signal?: AbortSignal;
50
+ }
51
+ export type VideoFrameResult = {
52
+ readonly ok: true;
53
+ readonly metadata: VideoMetadata;
54
+ readonly frames: readonly VideoFrame[];
55
+ readonly sheets?: readonly VideoContactSheet[];
56
+ } | {
57
+ readonly ok: false;
58
+ readonly reason: 'unavailable' | 'busy' | 'too-large' | 'timeout' | 'canceled' | 'invalid';
59
+ readonly message: string;
60
+ };
61
+ /** Implementations return <=64 sampled frames: separate JPEGs <=512KiB or <=4 sheets <=2MiB.
62
+ * The count-dependent pixel policy and <=8MiB aggregate budget apply in both modes.
63
+ * Sheet mode returns frames: [] plus sheets; separate mode omits sheets. Overflow fails in full.
64
+ * Inputs are authorized bytes, never caller paths. No provider or CMS-owner concepts live here. */
65
+ export interface VideoFrameExtractor {
66
+ extract(required: {
67
+ bytes: Uint8Array;
68
+ }, optional: VideoFrameOptions): Promise<VideoFrameResult>;
69
+ }
70
+ /** Keep sixteen square 768px images worth of pixels (about 12.6k tokens at pixels/750).
71
+ * Even pixel sizes are codec-friendly; flooring never increases the budget. */
72
+ export declare function getVideoFrameEdge({ frameCount }: {
73
+ frameCount: number;
74
+ }, _optional?: Record<string, never>): number;
75
+ /** Grid pixels, including black unused cells, share the separate-frame pixel budget.
76
+ * Metadata provides labels without requiring a host font or ffmpeg drawtext support. */
77
+ export declare function planVideoContactSheets({ times }: {
78
+ times: readonly number[];
79
+ }, _optional?: Record<string, never>): VideoSheetLayout[];
80
+ /** Avoid seeking to exact EOF, which commonly has no decodable frame. Preserve explicit order,
81
+ * including repeated timestamps, so image positions continue to match the requested positions.
82
+ * With every and no count, stop when the next timestamp cannot fit (always emit the first clamped
83
+ * position). An explicit count preserves the requested count, clamping positions beyond the clip.
84
+ * start only affects every; at overrides count/start, but at + every is invalid. */
85
+ export declare function planVideoFrames({ duration }: {
86
+ duration: number;
87
+ }, { frames, at, every, start }?: Pick<VideoFrameOptions, 'frames' | 'at' | 'every' | 'start'>): number[];
88
+ /** Parse only the ffprobe fields requested by the adapter. File size is measured from authorized
89
+ * input bytes, never from a possibly stale container header. Cover art is not a video stream. */
90
+ export declare function parseVideoProbe({ output, sizeBytes }: {
91
+ output: string;
92
+ sizeBytes: number;
93
+ }, _optional?: Record<string, never>): VideoMetadata;
94
+ //# sourceMappingURL=video-frames.d.ts.map
@@ -0,0 +1,39 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { type VideoFrameExtractor } from './video-frames.js';
3
+ export interface VideoBinaries {
4
+ readonly nice: string;
5
+ readonly ffmpeg: string;
6
+ readonly ffprobe: string;
7
+ }
8
+ export type VideoBinaryFinder = (required: Record<string, never>, optional: Record<string, never>) => Promise<VideoBinaries | undefined>;
9
+ export type VideoProcessRunner = (required: {
10
+ command: string;
11
+ args: readonly string[];
12
+ }, optional: {
13
+ timeoutMs: number;
14
+ maxOutputBytes: number;
15
+ signal?: AbortSignal;
16
+ }) => Promise<Uint8Array>;
17
+ /** Discovery never executes a version/probe command. Configured paths are host configuration,
18
+ * never tool arguments, and all subprocesses (including ffprobe) still go through nice. */
19
+ export declare const findVideoBinaries: VideoBinaryFinder;
20
+ /** detached creates a group including nice and its eventual executable; killing the group prevents
21
+ * a wrapper from leaving a decoder alive. Waiting for close keeps the extraction lock until exit. */
22
+ export declare const runLowPriorityVideoProcess: ({ command, args, spawnProcess, killGroup }: {
23
+ command: string;
24
+ args: readonly string[];
25
+ spawnProcess?: typeof spawn;
26
+ killGroup?: (required: {
27
+ pid: number;
28
+ }, optional: Record<string, never>) => void;
29
+ }, { timeoutMs, maxOutputBytes, signal }: {
30
+ timeoutMs: number;
31
+ maxOutputBytes: number;
32
+ signal?: AbortSignal;
33
+ }) => Promise<Uint8Array>;
34
+ export declare function createFfmpegVideoFrameExtractor({ findBinaries, runProcess, nowMs }: {
35
+ findBinaries?: VideoBinaryFinder;
36
+ runProcess?: VideoProcessRunner;
37
+ nowMs?: (required: Record<string, never>, optional: Record<string, never>) => number;
38
+ }, _optional?: Record<string, never>): VideoFrameExtractor;
39
+ //# sourceMappingURL=video-frames.ffmpeg.d.ts.map
@@ -0,0 +1,261 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { constants } from 'node:fs';
3
+ import { access, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { performance } from 'node:perf_hooks';
6
+ import { delimiter, isAbsolute, join } from 'node:path';
7
+ import { sniffContentType } from './content-type-sniffer.js';
8
+ import { getVideoFrameEdge, parseVideoProbe, planVideoFrames, planVideoContactSheets, VIDEO_EXTRACTION_TIMEOUT_MS, VIDEO_MAX_SHEET_BYTES, VIDEO_MAX_FRAME_BYTES, VIDEO_MAX_INPUT_BYTES, VIDEO_MAX_OUTPUT_BYTES } from './video-frames.js';
9
+ class VideoProcessError extends Error {
10
+ reason;
11
+ constructor(reason, message) {
12
+ super(message);
13
+ this.reason = reason;
14
+ }
15
+ }
16
+ async function executable(required, { configured } = {}) {
17
+ const candidates = configured ? [configured] : [
18
+ ...(process.env['PATH'] ?? '').split(delimiter).filter(isAbsolute).map(directory => join(directory, required.name)),
19
+ join('/opt/homebrew/bin', required.name), join('/usr/local/bin', required.name), join('/usr/bin', required.name),
20
+ ];
21
+ for (const candidate of candidates) {
22
+ if (!isAbsolute(candidate))
23
+ continue;
24
+ try {
25
+ await access(candidate, constants.X_OK);
26
+ if ((await stat(candidate)).isFile())
27
+ return candidate;
28
+ }
29
+ catch { /* Try the next host executable. */ }
30
+ }
31
+ return undefined;
32
+ }
33
+ /** Discovery never executes a version/probe command. Configured paths are host configuration,
34
+ * never tool arguments, and all subprocesses (including ffprobe) still go through nice. */
35
+ export const findVideoBinaries = async (_required, _optional) => {
36
+ if (process.platform === 'win32')
37
+ return undefined; // Never silently omit the low-priority guarantee.
38
+ const nice = await executable({ name: 'nice' }, {});
39
+ const ffmpeg = await executable({ name: 'ffmpeg' }, { configured: process.env['FFMPEG_PATH'] });
40
+ const ffprobe = await executable({ name: 'ffprobe' }, { configured: process.env['FFPROBE_PATH'] });
41
+ return nice && ffmpeg && ffprobe ? { nice, ffmpeg, ffprobe } : undefined;
42
+ };
43
+ /** detached creates a group including nice and its eventual executable; killing the group prevents
44
+ * a wrapper from leaving a decoder alive. Waiting for close keeps the extraction lock until exit. */
45
+ export const runLowPriorityVideoProcess = async ({ command, args, spawnProcess = spawn, killGroup = ({ pid }) => { process.kill(-pid, 'SIGKILL'); } }, { timeoutMs, maxOutputBytes, signal }) => {
46
+ if (signal?.aborted)
47
+ throw new VideoProcessError('canceled', 'Video preview canceled.');
48
+ if (timeoutMs <= 0)
49
+ throw new VideoProcessError('timeout', 'Video preview timed out.');
50
+ return new Promise((resolve, reject) => {
51
+ const child = spawnProcess(command, [...args], { detached: true, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
52
+ const chunks = [];
53
+ let bytes = 0;
54
+ let stopped;
55
+ let settled = false;
56
+ const stop = (error) => {
57
+ if (stopped || settled)
58
+ return;
59
+ stopped = error;
60
+ try {
61
+ if (child.pid === undefined)
62
+ child.kill('SIGKILL');
63
+ else
64
+ killGroup({ pid: child.pid }, {});
65
+ }
66
+ catch {
67
+ child.kill('SIGKILL');
68
+ }
69
+ };
70
+ const timer = setTimeout(() => stop(new VideoProcessError('timeout', 'Video preview timed out.')), timeoutMs);
71
+ const abort = () => stop(new VideoProcessError('canceled', 'Video preview canceled.'));
72
+ const finish = (error) => {
73
+ if (settled)
74
+ return;
75
+ settled = true;
76
+ clearTimeout(timer);
77
+ signal?.removeEventListener('abort', abort);
78
+ if (error)
79
+ reject(error);
80
+ else
81
+ resolve(Buffer.concat(chunks));
82
+ };
83
+ child.stdout?.on('data', (chunk) => {
84
+ if (stopped)
85
+ return;
86
+ bytes += chunk.length;
87
+ if (bytes > maxOutputBytes) {
88
+ stop(new VideoProcessError('invalid', 'Video preview exceeded its output limit.'));
89
+ return;
90
+ }
91
+ chunks.push(chunk);
92
+ });
93
+ // Drain diagnostics but do not retain unbounded stderr or expose host paths to the model.
94
+ child.stderr?.resume();
95
+ child.once('error', (error) => finish(new VideoProcessError(error.code === 'ENOENT' || error.code === 'EACCES' ? 'unavailable' : 'invalid', 'Video preview process could not start.')));
96
+ child.once('close', (code) => finish(stopped ?? (code === 0 ? undefined : new VideoProcessError('invalid', 'Video preview could not decode this clip.'))));
97
+ signal?.addEventListener('abort', abort, { once: true });
98
+ if (signal?.aborted)
99
+ abort();
100
+ });
101
+ };
102
+ // Shared across ALL adapter instances: a second catalog/tenant cannot run a second ffmpeg in this
103
+ // process. Fail busy instead of holding arbitrary numbers of uploaded videos in a pending queue.
104
+ let extracting = false;
105
+ const UNAVAILABLE = 'video preview unavailable on this host (nice, ffmpeg and ffprobe are required).';
106
+ // Eight amortizes process/seek overhead while bounding expensive source decoders to
107
+ // one eighth of the 64-frame maximum. Thread caps alone do not bound decoder memory.
108
+ // This is a decoder-count limit, not a measured host RSS guarantee.
109
+ const SOURCE_BATCH_SIZE = 8;
110
+ const localInputFlags = ['-threads', '1', '-protocol_whitelist', 'file', '-format_whitelist', 'mov,matroska,webm'];
111
+ export function createFfmpegVideoFrameExtractor({ findBinaries = findVideoBinaries, runProcess = runLowPriorityVideoProcess, nowMs = () => performance.now() }, _optional = {}) {
112
+ return { extract: async ({ bytes }, options) => {
113
+ if (bytes.byteLength > VIDEO_MAX_INPUT_BYTES)
114
+ return { ok: false, reason: 'too-large', message: `Video preview input exceeds ${VIDEO_MAX_INPUT_BYTES} bytes.` };
115
+ const type = sniffContentType({ bytes });
116
+ if (type !== 'video/mp4' && type !== 'video/webm')
117
+ return { ok: false, reason: 'invalid', message: 'Video preview requires stored MP4 or WebM bytes.' };
118
+ if (extracting)
119
+ return { ok: false, reason: 'busy', message: 'Video preview is busy. Try again after the current extraction finishes.' };
120
+ extracting = true;
121
+ const deadline = nowMs({}, {}) + VIDEO_EXTRACTION_TIMEOUT_MS;
122
+ let directory;
123
+ try {
124
+ if (options.signal?.aborted)
125
+ throw new VideoProcessError('canceled', 'Video preview canceled.');
126
+ if (options.layout !== undefined && options.layout !== 'frames' && options.layout !== 'sheet')
127
+ throw new VideoProcessError('invalid', 'Video layout must be frames or sheet.');
128
+ const binaries = await findBinaries({}, {});
129
+ if (!binaries)
130
+ return { ok: false, reason: 'unavailable', message: UNAVAILABLE };
131
+ directory = await mkdtemp(join(tmpdir(), 'video-preview-'));
132
+ const input = join(directory, 'input');
133
+ await writeFile(input, bytes, { mode: 0o600, flag: 'wx' });
134
+ const checkDeadline = () => {
135
+ if (options.signal?.aborted)
136
+ throw new VideoProcessError('canceled', 'Video preview canceled.');
137
+ if (nowMs({}, {}) >= deadline)
138
+ throw new VideoProcessError('timeout', 'Video preview timed out.');
139
+ };
140
+ const run = (required, optional) => {
141
+ checkDeadline();
142
+ const timeoutMs = deadline - nowMs({}, {});
143
+ if (timeoutMs <= 0)
144
+ throw new VideoProcessError('timeout', 'Video preview timed out.');
145
+ return runProcess({ command: binaries.nice, args: ['-n', '19', required.binary, ...required.args] }, { timeoutMs, maxOutputBytes: optional.maxOutputBytes, ...(options.signal ? { signal: options.signal } : {}) });
146
+ };
147
+ const probe = await run({ binary: binaries.ffprobe, args: ['-v', 'error', ...localInputFlags, '-show_entries', 'format=duration,format_name:stream=codec_type,codec_name,width,height,duration:stream_disposition=attached_pic', '-of', 'json', '-i', input] }, { maxOutputBytes: 64 * 1024 });
148
+ const metadata = parseVideoProbe({ output: Buffer.from(probe).toString('utf8'), sizeBytes: bytes.byteLength }, {});
149
+ const times = planVideoFrames({ duration: metadata.duration }, options);
150
+ const sheetLayouts = options.layout === 'sheet' ? planVideoContactSheets({ times }, {}) : undefined;
151
+ const edge = sheetLayouts?.[0]?.tileEdgePx ?? getVideoFrameEdge({ frameCount: times.length }, {});
152
+ const imageCount = sheetLayouts?.length ?? times.length;
153
+ const perImageLimit = sheetLayouts ? VIDEO_MAX_SHEET_BYTES : VIDEO_MAX_FRAME_BYTES;
154
+ // Preallocate a fair share for each output, so all file limits together fit the total budget.
155
+ const fileLimit = Math.min(perImageLimit, Math.floor(VIDEO_MAX_OUTPUT_BYTES / imageCount));
156
+ const paths = Array.from({ length: imageCount }, (_, index) => join(directory, `image-${index}.jpg`));
157
+ const tilePaths = sheetLayouts ? times.map((_, index) => join(directory, `tile-${index}.jpg`)) : paths;
158
+ // Intermediates have their own bounded allocation; never spend a sheet's 2 MiB per tile.
159
+ const tileLimit = sheetLayouts ? Math.min(VIDEO_MAX_FRAME_BYTES, Math.floor(VIDEO_MAX_OUTPUT_BYTES / times.length)) : fileLimit;
160
+ const ffmpegArgs = () => ['-hide_banner', '-loglevel', 'error', '-nostdin', '-filter_threads', '1', '-filter_complex_threads', '1'];
161
+ // Framerate mode is per output (ffmpeg >=5.1); preserve each selected frame
162
+ // for both source extraction and contact-sheet assembly without resync.
163
+ const appendOutput = ({ args, label, path, limit }) => args.push('-map', label, '-an', '-sn', '-dn', '-frames:v', '1', '-c:v', 'mjpeg', '-threads', '1', '-q:v', '5', '-fs', String(limit), '-f', 'mjpeg', '-fps_mode', 'passthrough', path);
164
+ const readImage = async ({ path, perImageLimit, fileLimit }) => {
165
+ checkDeadline();
166
+ // -fs is packet-granular: check size before allocating and refuse overshoot.
167
+ const size = (await stat(path)).size;
168
+ checkDeadline();
169
+ if (size > perImageLimit)
170
+ throw new VideoProcessError('invalid', 'Video preview exceeded its output limit.');
171
+ if (size > fileLimit)
172
+ throw new VideoProcessError('invalid', 'Video preview exceeded its total output limit (8 MiB).');
173
+ const image = await readFile(path);
174
+ checkDeadline();
175
+ if (image.length < 4 || image[0] !== 255 || image[1] !== 216 || image[image.length - 2] !== 255 || image[image.length - 1] !== 217) {
176
+ throw new VideoProcessError('invalid', 'Video preview returned no JPEG frame.');
177
+ }
178
+ return image;
179
+ };
180
+ const scale = `scale=w='min(${edge},iw)':h='min(${edge},ih)':force_original_aspect_ratio=decrease`;
181
+ const images = [];
182
+ let tileBytes = 0;
183
+ // Await exit and validate this batch before starting another: <=8 source contexts alive.
184
+ for (let offset = 0; offset < times.length; offset += SOURCE_BATCH_SIZE) {
185
+ checkDeadline();
186
+ const batch = times.slice(offset, offset + SOURCE_BATCH_SIZE);
187
+ const args = ffmpegArgs();
188
+ // Seek each source input before opening it; no full-clip select/decode scan.
189
+ for (const at of batch)
190
+ args.push(...localInputFlags, '-ss', String(at), '-i', input);
191
+ const filters = batch.map((_, index) => `[${index}:V:0]trim=end_frame=1,setpts=PTS-STARTPTS,${scale},setsar=1${sheetLayouts ? `,pad=${edge}:${edge}:(ow-iw)/2:(oh-ih)/2:color=black` : ''}[tile${index}]`);
192
+ args.push('-filter_complex', filters.join(';'));
193
+ for (let index = 0; index < batch.length; index++)
194
+ appendOutput({ args, label: `[tile${index}]`, path: tilePaths[offset + index], limit: tileLimit });
195
+ // Raw MJPEG with one frame writes one JPEG through the file muxer (-fs applies).
196
+ await run({ binary: binaries.ffmpeg, args }, { maxOutputBytes: 64 * 1024 });
197
+ checkDeadline();
198
+ for (let index = 0; index < batch.length; index++) {
199
+ const image = await readImage({ path: tilePaths[offset + index], perImageLimit: VIDEO_MAX_FRAME_BYTES, fileLimit: tileLimit });
200
+ tileBytes += image.byteLength;
201
+ if (tileBytes > VIDEO_MAX_OUTPUT_BYTES)
202
+ throw new VideoProcessError('invalid', 'Video preview exceeded its total output limit (8 MiB).');
203
+ if (!sheetLayouts)
204
+ images.push(image); // Sheet tiles remain on disk, not retained in memory.
205
+ }
206
+ }
207
+ if (sheetLayouts) {
208
+ let offset = 0;
209
+ let outputBytes = 0;
210
+ for (const [index, sheet] of sheetLayouts.entries()) {
211
+ checkDeadline();
212
+ const inputs = tilePaths.slice(offset, offset + sheet.tiles.length);
213
+ const args = ffmpegArgs();
214
+ // These decoders see only small, validated JPEGs, never the source video.
215
+ for (const path of inputs)
216
+ args.push('-threads', '1', '-protocol_whitelist', 'file', '-format_whitelist', 'mjpeg', '-f', 'mjpeg', '-i', path);
217
+ const filters = inputs.map((_, tile) => `[${tile}:v:0]trim=end_frame=1,setpts=PTS-STARTPTS[tile${tile}]`);
218
+ const labels = inputs.map((_, tile) => `[tile${tile}]`).join('');
219
+ const positions = sheet.tiles.map(tile => `${(tile.column - 1) * edge}_${(tile.row - 1) * edge}`).join('|');
220
+ // Pad the grid if unused bottom-right cells do not establish the full extent.
221
+ filters.push(`${labels}${inputs.length === 1 ? 'null' : `xstack=inputs=${inputs.length}:layout=${positions}:fill=black:shortest=1`},pad=${sheet.columns * edge}:${sheet.rows * edge}:0:0:color=black[sheet]`);
222
+ args.push('-filter_complex', filters.join(';'));
223
+ appendOutput({ args, label: '[sheet]', path: paths[index], limit: fileLimit });
224
+ await run({ binary: binaries.ffmpeg, args }, { maxOutputBytes: 64 * 1024 });
225
+ const image = await readImage({ path: paths[index], perImageLimit, fileLimit });
226
+ outputBytes += image.byteLength;
227
+ if (outputBytes > VIDEO_MAX_OUTPUT_BYTES)
228
+ throw new VideoProcessError('invalid', 'Video preview exceeded its total output limit (8 MiB).');
229
+ images.push(image);
230
+ // Retire tiles as soon as their sheet is verified; finally still cleans on any failure.
231
+ for (const path of inputs) {
232
+ checkDeadline();
233
+ await rm(path);
234
+ }
235
+ offset += inputs.length;
236
+ }
237
+ }
238
+ checkDeadline();
239
+ if (sheetLayouts)
240
+ return { ok: true, metadata, frames: [], sheets: sheetLayouts.map((sheet, index) => ({ ...sheet, mimeType: 'image/jpeg', bytes: images[index] })) };
241
+ return { ok: true, metadata, frames: times.map((at, index) => ({ at, mimeType: 'image/jpeg', bytes: images[index] })) };
242
+ }
243
+ catch (error) {
244
+ const reason = error instanceof VideoProcessError ? error.reason : 'invalid';
245
+ return { ok: false, reason, message: reason === 'unavailable' ? UNAVAILABLE : error instanceof VideoProcessError ? error.message : 'Video preview could not read or decode this clip.' };
246
+ }
247
+ finally {
248
+ try {
249
+ if (directory)
250
+ await rm(directory, { recursive: true, force: true });
251
+ }
252
+ catch {
253
+ return { ok: false, reason: 'invalid', message: 'Video preview temporary files could not be removed.' };
254
+ }
255
+ finally {
256
+ extracting = false;
257
+ }
258
+ }
259
+ } };
260
+ }
261
+ //# sourceMappingURL=video-frames.ffmpeg.js.map
@@ -0,0 +1,97 @@
1
+ /** Video preview contract and sampling policy, independent of a host, codec or AI provider. */
2
+ export const VIDEO_MAX_FRAMES = 64;
3
+ export const VIDEO_DEFAULT_FRAMES = 16;
4
+ export const VIDEO_MAX_EDGE_PX = 768;
5
+ export const VIDEO_MAX_INPUT_BYTES = 64 * 1024 * 1024;
6
+ export const VIDEO_MAX_FRAME_BYTES = 512 * 1024;
7
+ /** Aggregate raw JPEG payload budget; base64 expands this to about 10.67 MiB plus metadata. */
8
+ export const VIDEO_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
9
+ export const VIDEO_MAX_SHEET_BYTES = 2 * 1024 * 1024;
10
+ export const VIDEO_MAX_SHEETS = 4;
11
+ // One shared deadline: serial source batches and JPEG sheet assembly need launch/seek
12
+ // headroom at nice 19. This remains a hard bound, not a per-process allowance.
13
+ export const VIDEO_EXTRACTION_TIMEOUT_MS = 60_000;
14
+ /** Keep sixteen square 768px images worth of pixels (about 12.6k tokens at pixels/750).
15
+ * Even pixel sizes are codec-friendly; flooring never increases the budget. */
16
+ export function getVideoFrameEdge({ frameCount }, _optional = {}) {
17
+ if (!Number.isInteger(frameCount) || frameCount < 1 || frameCount > VIDEO_MAX_FRAMES)
18
+ throw new Error('Frame count must be an integer from 1 to 64.');
19
+ return 2 * Math.floor(VIDEO_MAX_EDGE_PX * Math.min(1, Math.sqrt(VIDEO_DEFAULT_FRAMES / frameCount)) / 2);
20
+ }
21
+ /** Grid pixels, including black unused cells, share the separate-frame pixel budget.
22
+ * Metadata provides labels without requiring a host font or ffmpeg drawtext support. */
23
+ export function planVideoContactSheets({ times }, _optional = {}) {
24
+ if (!Array.isArray(times) || times.length < 1 || times.length > VIDEO_MAX_FRAMES || times.some(time => typeof time !== 'number' || !Number.isFinite(time) || time < 0)) {
25
+ throw new Error('Sheets require 1 to 64 finite nonnegative timestamps.');
26
+ }
27
+ const groups = [];
28
+ for (let offset = 0; offset < times.length; offset += 16) {
29
+ const group = times.slice(offset, offset + 16);
30
+ const columns = Math.min(4, Math.ceil(Math.sqrt(group.length)));
31
+ const rows = Math.ceil(group.length / columns);
32
+ groups.push({ columns, rows, tileEdgePx: 0, tiles: group.map((at, index) => ({ index: index + 1, row: Math.floor(index / columns) + 1, column: index % columns + 1, at })) });
33
+ }
34
+ const frameCount = groups.reduce((sum, group) => sum + group.columns * group.rows, 0);
35
+ const tileEdgePx = getVideoFrameEdge({ frameCount }, {});
36
+ return groups.map(group => ({ ...group, tileEdgePx }));
37
+ }
38
+ /** Avoid seeking to exact EOF, which commonly has no decodable frame. Preserve explicit order,
39
+ * including repeated timestamps, so image positions continue to match the requested positions.
40
+ * With every and no count, stop when the next timestamp cannot fit (always emit the first clamped
41
+ * position). An explicit count preserves the requested count, clamping positions beyond the clip.
42
+ * start only affects every; at overrides count/start, but at + every is invalid. */
43
+ export function planVideoFrames({ duration }, { frames, at, every, start = 0 } = {}) {
44
+ if (!Number.isFinite(duration) || duration <= 0)
45
+ throw new Error('Video duration must be positive and finite.');
46
+ if (frames !== undefined && (typeof frames !== 'number' || !Number.isFinite(frames)))
47
+ throw new Error('Frame count must be finite.');
48
+ if (frames !== undefined && frames < 0)
49
+ throw new Error('Frame count must be nonnegative.');
50
+ if (at !== undefined && (!Array.isArray(at) || !at.length || at.some(time => typeof time !== 'number' || !Number.isFinite(time)))) {
51
+ throw new Error('at must contain finite timestamps in seconds.');
52
+ }
53
+ if (at?.some(time => time < 0))
54
+ throw new Error('at timestamps must be nonnegative.');
55
+ if (every !== undefined && (typeof every !== 'number' || !Number.isFinite(every) || every <= 0))
56
+ throw new Error('every must be a positive finite number of seconds.');
57
+ if (typeof start !== 'number' || !Number.isFinite(start) || start < 0)
58
+ throw new Error('start must be a nonnegative finite number of seconds.');
59
+ if (at !== undefined && every !== undefined)
60
+ throw new Error('every cannot be combined with at.');
61
+ const inset = Math.min(0.1, duration / 10);
62
+ const end = duration - inset;
63
+ if (at !== undefined) {
64
+ return at.slice(0, VIDEO_MAX_FRAMES).map(time => Math.max(0, Math.min(end, time)));
65
+ }
66
+ const count = Math.max(1, Math.min(VIDEO_MAX_FRAMES, Math.floor(frames ?? (every !== undefined ? VIDEO_MAX_FRAMES : VIDEO_DEFAULT_FRAMES))));
67
+ if (every !== undefined) {
68
+ const times = [];
69
+ for (let index = 0; index < count; index++) {
70
+ const time = start + index * every;
71
+ // Multiplication can round a fitting boundary just above end. Only tolerate arithmetic
72
+ // roundoff; a materially later timestamp must not add an EOF sample when count is omitted.
73
+ const tolerance = Number.EPSILON * Math.max(end, Math.abs(time)) * 4;
74
+ if (frames === undefined && index > 0 && (!Number.isFinite(time) || time - end > tolerance))
75
+ break;
76
+ times.push(Math.min(end, time));
77
+ }
78
+ return times;
79
+ }
80
+ if (count === 1)
81
+ return [duration / 2];
82
+ return Array.from({ length: count }, (_, index) => inset + (end - inset) * index / (count - 1));
83
+ }
84
+ /** Parse only the ffprobe fields requested by the adapter. File size is measured from authorized
85
+ * input bytes, never from a possibly stale container header. Cover art is not a video stream. */
86
+ export function parseVideoProbe({ output, sizeBytes }, _optional = {}) {
87
+ const raw = JSON.parse(output);
88
+ const streams = Array.isArray(raw?.streams) ? raw.streams : [];
89
+ const video = streams.find(stream => stream.codec_type === 'video' && stream.disposition?.attached_pic !== 1);
90
+ const formatDuration = Number(raw?.format?.duration);
91
+ const duration = Number.isFinite(formatDuration) && formatDuration > 0 ? formatDuration : Number(video?.duration);
92
+ if (!video || !Number.isFinite(duration) || duration <= 0 || !Number.isInteger(video.width) || !Number.isInteger(video.height) || video.width <= 0 || video.height <= 0) {
93
+ throw new Error('Video metadata is missing or invalid.');
94
+ }
95
+ return { duration, width: video.width, height: video.height, codec: video.codec_name ?? 'unknown', container: raw.format?.format_name ?? 'unknown', sizeBytes, hasAudio: streams.some(stream => stream.codec_type === 'audio') };
96
+ }
97
+ //# sourceMappingURL=video-frames.js.map
@@ -0,0 +1,80 @@
1
+ /**
2
+ * @file `RedirectSlugChangeCapture` — the `SlugChangeCapture` implementation
3
+ * (SPEC-009 REQ-15/16/17; ADR-PIPE-009 Decision A).
4
+ *
5
+ * Purpose:
6
+ * The single implementer of `routing`'s `SlugChangeCapture` slot. Inserts an
7
+ * `active`, `source: 'auto_slug_change'`, `matchType: 'exact'` redirect rule +
8
+ * its revision when an entry's routable slug/path changes, so no request can
9
+ * observe the moved entry without its redirect already existing (INV-02).
10
+ *
11
+ * Correctness discipline (Decision A, load-bearing): this class performs NO
12
+ * transaction control of its own — no `BEGIN`/`COMMIT`/`ROLLBACK` anywhere in
13
+ * this file. It relies entirely on the caller (the content write chokepoint,
14
+ * once wired — see tasks.md's "Known Scope Boundary": that wiring is OUT OF
15
+ * SCOPE for this feature) already holding an open transaction on the same
16
+ * single-connection `better-sqlite3` handle. Writes go through
17
+ * `ports.internal.ts`'s `insertRedirectAndRevision`, the one shared,
18
+ * non-tx-opening insert path this file and `redirects.ts` both use.
19
+ *
20
+ * Idempotency (REQ-17/AC-20): keyed off the routing-owned
21
+ * `SlugChangeCaptureInput`'s `oldPath`/`newPath` rather than a `changeSetId`
22
+ * DB column — `state.spec.md`'s frozen `RedirectRecord`/`RedirectRevision`
23
+ * shapes (mirrored from the already-written `src/redirects/types.ts`) carry
24
+ * no `changeSetId` field, so this reuses the existing
25
+ * `RedirectRepoPort.lookupExact` read (already on the frozen `ports.ts`)
26
+ * to detect "this exact slug-change was already captured": when the rule that
27
+ * requests for `oldPath` actually follow (`RedirectRepoPort.lookupExact`, the
28
+ * resolver's own newest-wins order) is an `active`, `source: 'auto_slug_change'`
29
+ * rule with `toTarget === newPath`, the same change was already captured, so a
30
+ * retry is a no-op rather than a duplicate insert. It must be the WINNING rule,
31
+ * not just any historical match: after `a→b→a→c→a`, an older `/a → /b` still
32
+ * exists but the newer `/a → /c` wins, so renaming `a→b` again has to insert a
33
+ * fresh `/a → /b` or `/a` keeps resolving to `/c`, whose own rule points back
34
+ * at `/a` — a permanent loop.
35
+ *
36
+ * Architectural role:
37
+ * Feature logic. No Express/route code, no direct SQL — writes go through the
38
+ * injected `RedirectDbHandle`; reads go through the injected `RedirectRepoPort`.
39
+ */
40
+ import type { Clock as ClockPort, IdGenerator as IdGeneratorPort } from "@jini-ai/core/primitives";
41
+ import type { SlugChangeCapture, SlugChangeCaptureInput } from "./ports.js";
42
+ import { type RedirectDbHandle } from "./ports.internal.js";
43
+ import type { RedirectRecord } from "./types.js";
44
+ /**
45
+ * The narrow read this capture needs — `RedirectRepoPort.lookupExact` only
46
+ * (idempotency check against the rule resolution actually picks). Declared as
47
+ * a `Pick` rather than the full `RedirectRepoPort` so this file's dependency
48
+ * surface documents exactly what it reads.
49
+ */
50
+ export interface RedirectSlugChangeCaptureReadDeps {
51
+ lookupExact(required: {
52
+ workspaceId: string;
53
+ path: string;
54
+ includeOverrideOnly: boolean;
55
+ }, optional?: Record<string, never>): Promise<RedirectRecord | null>;
56
+ }
57
+ export interface RedirectSlugChangeCaptureDeps {
58
+ repo: RedirectSlugChangeCaptureReadDeps;
59
+ db: RedirectDbHandle;
60
+ clock: ClockPort;
61
+ idGen: IdGeneratorPort;
62
+ }
63
+ export declare class RedirectSlugChangeCapture implements SlugChangeCapture {
64
+ private readonly deps;
65
+ constructor(deps: RedirectSlugChangeCaptureDeps, _optional?: Record<string, never>);
66
+ /**
67
+ * Insert the auto-capture redirect rule for a slug change, or no-op if the
68
+ * exact same change was already captured (idempotent retry, AC-20).
69
+ *
70
+ * MUST be called by a caller that already holds an open transaction on the
71
+ * shared connection (or accepts the no-transaction-safety consequences of
72
+ * calling it outside one) — this method never opens or closes one itself.
73
+ *
74
+ * @throws whatever `db.insertRedirect`/`db.insertRevision` throws,
75
+ * uncaught, so the caller's own transaction aborts (REQ-16/EC-05).
76
+ * @complexity O(1) — one read (idempotency check) + two writes.
77
+ */
78
+ onSlugChange(input: SlugChangeCaptureInput, _optional?: Record<string, never>): Promise<void>;
79
+ }
80
+ //# sourceMappingURL=capture.d.ts.map
@@ -0,0 +1,66 @@
1
+ import { nowIso as clockNowIso } from "@jini-ai/core/primitives";
2
+ import { insertRedirectAndRevision } from "./ports.internal.js";
3
+ /** Default priority/statusCode/override for an auto-captured rule (behavior.spec.md §3 analog). */
4
+ const AUTO_CAPTURE_STATUS_CODE = 301;
5
+ const AUTO_CAPTURE_PRIORITY = 0;
6
+ export class RedirectSlugChangeCapture {
7
+ deps;
8
+ constructor(deps, _optional = {}) {
9
+ this.deps = deps;
10
+ }
11
+ /**
12
+ * Insert the auto-capture redirect rule for a slug change, or no-op if the
13
+ * exact same change was already captured (idempotent retry, AC-20).
14
+ *
15
+ * MUST be called by a caller that already holds an open transaction on the
16
+ * shared connection (or accepts the no-transaction-safety consequences of
17
+ * calling it outside one) — this method never opens or closes one itself.
18
+ *
19
+ * @throws whatever `db.insertRedirect`/`db.insertRevision` throws,
20
+ * uncaught, so the caller's own transaction aborts (REQ-16/EC-05).
21
+ * @complexity O(1) — one read (idempotency check) + two writes.
22
+ */
23
+ async onSlugChange(input, _optional = {}) {
24
+ const existing = await this.deps.repo.lookupExact({
25
+ workspaceId: input.workspaceId,
26
+ path: input.oldPath,
27
+ includeOverrideOnly: false,
28
+ });
29
+ if (existing && existing.source === "auto_slug_change" && existing.toTarget === input.newPath) {
30
+ // Same change already captured (retry of the same changeSetId) — idempotent no-op.
31
+ return;
32
+ }
33
+ const now = clockNowIso({ clock: this.deps.clock });
34
+ const id = this.deps.idGen.newId();
35
+ const record = {
36
+ id,
37
+ workspaceId: input.workspaceId,
38
+ matchType: "exact",
39
+ fromPattern: input.oldPath,
40
+ toTarget: input.newPath,
41
+ statusCode: AUTO_CAPTURE_STATUS_CODE,
42
+ status: "active",
43
+ override: false,
44
+ priority: AUTO_CAPTURE_PRIORITY,
45
+ source: "auto_slug_change",
46
+ sourceEntryId: input.entryId,
47
+ fromPathAtCapture: input.oldPath,
48
+ toPathAtCapture: input.newPath,
49
+ createdByPrincipal: input.actor,
50
+ createdAt: now,
51
+ updatedAt: now,
52
+ version: 1,
53
+ };
54
+ const revision = {
55
+ redirectId: id,
56
+ workspaceId: input.workspaceId,
57
+ seq: 1,
58
+ state: record,
59
+ tombstoned: false,
60
+ actorId: input.actor,
61
+ recordedAt: now,
62
+ };
63
+ await insertRedirectAndRevision({ db: this.deps.db, record, revision });
64
+ }
65
+ }
66
+ //# sourceMappingURL=capture.js.map