@norskvideo/ctl-product-template-schema 0.1.19 → 0.1.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.
package/index.d.ts CHANGED
@@ -122,6 +122,17 @@ export declare const ProductTemplateRequirementsSchema: z.ZodObject<{
122
122
  }, z.core.$strip>>;
123
123
  }, z.core.$strip>;
124
124
  export type ProductTemplateRequirements = z.infer<typeof ProductTemplateRequirementsSchema>;
125
+ export declare const PRODUCT_TEMPLATE_MIGRATION_STRATEGIES: readonly ["overlap", "stopThenStart", "handover"];
126
+ export type MigrationStrategy = (typeof PRODUCT_TEMPLATE_MIGRATION_STRATEGIES)[number];
127
+ export declare const DEFAULT_MIGRATION_STRATEGY: MigrationStrategy;
128
+ export declare const ProductTemplateMigrationSchema: z.ZodObject<{
129
+ strategy: z.ZodEnum<{
130
+ overlap: "overlap";
131
+ stopThenStart: "stopThenStart";
132
+ handover: "handover";
133
+ }>;
134
+ }, z.core.$strip>;
135
+ export type ProductTemplateMigration = z.infer<typeof ProductTemplateMigrationSchema>;
125
136
  export declare const ProductTemplateManifestSchema: z.ZodObject<{
126
137
  productTemplateSchemaVersion: z.ZodLiteral<1>;
127
138
  productName: z.ZodString;
@@ -159,6 +170,7 @@ export declare const ProductTemplateManifestSchema: z.ZodObject<{
159
170
  isStudio: z.ZodOptional<z.ZodBoolean>;
160
171
  supportsCustomAssets: z.ZodOptional<z.ZodBoolean>;
161
172
  requiresWorkingDirectory: z.ZodOptional<z.ZodBoolean>;
173
+ studioAccessModes: z.ZodOptional<z.ZodArray<z.ZodString>>;
162
174
  sideload: z.ZodOptional<z.ZodObject<{
163
175
  gpuInference: z.ZodOptional<z.ZodBoolean>;
164
176
  models: z.ZodOptional<z.ZodBoolean>;
@@ -267,8 +279,19 @@ export declare const ProductTemplateManifestSchema: z.ZodObject<{
267
279
  preferred: z.ZodNumber;
268
280
  label: z.ZodOptional<z.ZodString>;
269
281
  }, z.core.$strip>>>;
282
+ migration: z.ZodOptional<z.ZodObject<{
283
+ strategy: z.ZodEnum<{
284
+ overlap: "overlap";
285
+ stopThenStart: "stopThenStart";
286
+ handover: "handover";
287
+ }>;
288
+ }, z.core.$strip>>;
270
289
  }, z.core.$strip>;
271
290
  export type ProductTemplateManifest = z.infer<typeof ProductTemplateManifestSchema>;
291
+ /** The migration strategy a manifest promises — `stopThenStart` when it
292
+ * declares none, since that is the only strategy safe for a product that
293
+ * has said nothing about how it tolerates being moved. */
294
+ export declare function migrationStrategyOf(manifest: Pick<ProductTemplateManifest, "migration">): MigrationStrategy;
272
295
  export declare const STANDARD_ADVANCED_OVERRIDES: {
273
296
  readonly containerUser: {};
274
297
  readonly publicHost: {};
@@ -354,6 +377,7 @@ export declare const ProductTemplateMaterialsSchema: z.ZodObject<{
354
377
  isStudio: z.ZodOptional<z.ZodBoolean>;
355
378
  supportsCustomAssets: z.ZodOptional<z.ZodBoolean>;
356
379
  requiresWorkingDirectory: z.ZodOptional<z.ZodBoolean>;
380
+ studioAccessModes: z.ZodOptional<z.ZodArray<z.ZodString>>;
357
381
  sideload: z.ZodOptional<z.ZodObject<{
358
382
  gpuInference: z.ZodOptional<z.ZodBoolean>;
359
383
  models: z.ZodOptional<z.ZodBoolean>;
@@ -462,6 +486,13 @@ export declare const ProductTemplateMaterialsSchema: z.ZodObject<{
462
486
  preferred: z.ZodNumber;
463
487
  label: z.ZodOptional<z.ZodString>;
464
488
  }, z.core.$strip>>>;
489
+ migration: z.ZodOptional<z.ZodObject<{
490
+ strategy: z.ZodEnum<{
491
+ overlap: "overlap";
492
+ stopThenStart: "stopThenStart";
493
+ handover: "handover";
494
+ }>;
495
+ }, z.core.$strip>>;
465
496
  }, z.core.$strip>;
466
497
  composeYaml: z.ZodString;
467
498
  parameters: z.ZodOptional<z.ZodObject<{
package/index.js CHANGED
@@ -262,6 +262,20 @@ export const ProductTemplateRequirementsSchema = z
262
262
  .meta({ description: "Fixed host sockets the job binds (exclusive)." }),
263
263
  })
264
264
  .meta({ id: "ProductTemplateRequirements", outputId: "ProductTemplateRequirements" });
265
+ // How a running instance of the product may be moved between nodes
266
+ // (norsk-mgr/docs/internal/design/job-migration.md §6). The manifest
267
+ // declares the strongest promise the product keeps; a migrate request may
268
+ // only down-grade to a safer strategy. Absent means `stopThenStart`, the
269
+ // one that is safe for any product.
270
+ export const PRODUCT_TEMPLATE_MIGRATION_STRATEGIES = ["overlap", "stopThenStart", "handover"];
271
+ export const DEFAULT_MIGRATION_STRATEGY = "stopThenStart";
272
+ export const ProductTemplateMigrationSchema = z
273
+ .object({
274
+ strategy: z.enum(PRODUCT_TEMPLATE_MIGRATION_STRATEGIES).meta({
275
+ description: "`overlap`: the target is started before the source is stopped, so both instances may produce briefly — a promise only a product whose outputs tolerate a second writer can make. `stopThenStart` (the default when absent): the target is prepared but not started, the source is stopped, then the target is started — safe for any product, at the cost of a gap. `handover`: the job speaks the worker's migration socket and hands over at an exact position, so the output continues seamlessly — a promise the job's own code keeps.",
276
+ }),
277
+ })
278
+ .meta({ id: "ProductTemplateMigration", outputId: "ProductTemplateMigration" });
265
279
  export const ProductTemplateManifestSchema = z
266
280
  .object({
267
281
  productTemplateSchemaVersion: z.literal(1),
@@ -292,6 +306,9 @@ export const ProductTemplateManifestSchema = z
292
306
  requiresWorkingDirectory: z.boolean().optional().meta({
293
307
  description: "When true, the operator must choose a working directory at launch (a dev product template they own and keep), rather than getting the auto-assigned per-instance default. The runner's launch UI makes the working-directory field mandatory.",
294
308
  }),
309
+ studioAccessModes: z.array(z.string().min(1)).optional().meta({
310
+ description: "The NORSK_STUDIO_ACCESS values the Studio image THIS product template pins understands. The launcher sets that variable only when the mode it is in appears here; absent, it sets nothing and Studio runs with its gate dormant, as it does today. Declared here rather than on the product manifest because this template's compose.yml is what pins the Studio image, so the declaration and the image it vouches for travel in one tar. A list rather than a boolean: a launcher must never send a word this image predates, and an unrecognised value resolves to refusing everything inside Studio.",
311
+ }),
295
312
  sideload: ProductTemplateSideloadSchema.optional().meta({
296
313
  description: "CUDA sideload bundle needs. Only gpu-inference is declared (gpu-video follows the GPU reservation automatically). The runner mounts the matching bundle when the image is slimmed and the GPU is reserved.",
297
314
  }),
@@ -304,8 +321,17 @@ export const ProductTemplateManifestSchema = z
304
321
  allocatedPorts: z.array(ProductTemplateAllocatedPortSchema).optional().meta({
305
322
  description: "Host-port bindings whose value rides a product-template parameter. Opt-in per launch via the param value: absent = closed, 'auto' = allocate a free port from `preferred`, number = use it. When opened the port is published as a managed host binding and surfaced to the containers via compose interpolation. Alongside the per-param scalars the runner sets NORSK_ALLOCATED_PORTS: a JSON object keyed by param, each value {port, protocol, service?}, describing every opened port ('{}' when none opted in; unset when nothing is declared) — so a product needing the allocation as data maps one var instead of folding scalars into JSON via interpolation.",
306
323
  }),
324
+ migration: ProductTemplateMigrationSchema.optional().meta({
325
+ description: "How a running instance may be moved between nodes. `strategy` is the strongest promise the product keeps: `overlap` (target started, then source stopped — both may produce briefly), `stopThenStart` (the default when absent — target prepared but not started, source stopped, target started), or `handover` (the job speaks the worker's migration socket and hands over at an exact position). A migrate request may down-grade to a safer strategy but never up-grade past the declaration. Only the manager reads this; the worker never does.",
326
+ }),
307
327
  })
308
328
  .meta({ id: "ProductTemplateManifest", outputId: "ProductTemplateManifest" });
329
+ /** The migration strategy a manifest promises — `stopThenStart` when it
330
+ * declares none, since that is the only strategy safe for a product that
331
+ * has said nothing about how it tolerates being moved. */
332
+ export function migrationStrategyOf(manifest) {
333
+ return manifest.migration?.strategy ?? DEFAULT_MIGRATION_STRATEGY;
334
+ }
309
335
  // Container user and external URL are runner knobs every product wants
310
336
  // overridable per instance — the runner only holds their default values.
311
337
  // Spread this into a manifest's `advanced` (adding product-specific keys
@@ -0,0 +1,120 @@
1
+ import { z } from "zod";
2
+ export declare const JOB_SOCKET_DEFAULT_PORT = 6797;
3
+ export declare const JOB_SOCKET_MAX_FRAME_BYTES: number;
4
+ export declare const JOB_SOCKET_HELLO_TIMEOUT_MS = 5000;
5
+ /** The name a container reaches its own worker's host under. Injected as an
6
+ * `extra_hosts` mapping on every service so it resolves on native Linux too. */
7
+ export declare const JOB_SOCKET_CONTAINER_HOST = "host.docker.internal";
8
+ /** The NodeInventory capability a worker advertises while its listener is up,
9
+ * so placement can require it for a handover target. */
10
+ export declare const JOB_SOCKET_CAPABILITY = "norsk.io/job-socket";
11
+ /** The two reserved env names a job reads. Absent when the worker runs
12
+ * without a listener (`--no-job-socket`), which is what `connectMigration`
13
+ * turns into its no-op client. */
14
+ export declare const JOB_SOCKET_ENV: {
15
+ readonly url: "NORSK_WORKER_WS_URL";
16
+ readonly token: "NORSK_WORKER_TOKEN";
17
+ };
18
+ /** Close codes the worker uses. All in the 4000–4999 application range so a
19
+ * client can tell them from transport failures. */
20
+ export declare const JobSocketCloseCode: {
21
+ /** A frame that breaks the protocol: unparseable, wrong side, wrong phase. */
22
+ readonly protocolError: 4400;
23
+ /** No hello in time, a wrong token, or a path naming no instance. */
24
+ readonly unauthorized: 4401;
25
+ /** A newer connection for the same instance replaced this one. */
26
+ readonly replaced: 4409;
27
+ /** A frame over the size cap. */
28
+ readonly frameTooLarge: 4413;
29
+ };
30
+ export declare function jobSocketPath(jobId: string, role: string): string;
31
+ /** The inverse of {@link jobSocketPath}. Query strings are ignored. */
32
+ export declare function parseJobSocketPath(path: string): {
33
+ jobId: string;
34
+ role: string;
35
+ } | undefined;
36
+ export declare function jobSocketUrl(args: {
37
+ host: string;
38
+ port: number;
39
+ jobId: string;
40
+ role: string;
41
+ }): string;
42
+ declare const JsonPayload: z.ZodJSONSchema;
43
+ /** Any JSON value — what a job may put in `payload`. */
44
+ export type MigrationPayload = z.infer<typeof JsonPayload>;
45
+ export declare const JobToWorkerMessageSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
46
+ type: z.ZodLiteral<"hello">;
47
+ token: z.ZodString;
48
+ }, z.core.$strip>, z.ZodObject<{
49
+ type: z.ZodLiteral<"state">;
50
+ payload: z.ZodJSONSchema;
51
+ }, z.core.$strip>, z.ZodObject<{
52
+ type: z.ZodLiteral<"state-refused">;
53
+ reason: z.ZodString;
54
+ }, z.core.$strip>, z.ZodObject<{
55
+ type: z.ZodLiteral<"target-ready">;
56
+ payload: z.ZodJSONSchema;
57
+ }, z.core.$strip>, z.ZodObject<{
58
+ type: z.ZodLiteral<"source-stopped-output">;
59
+ payload: z.ZodJSONSchema;
60
+ }, z.core.$strip>, z.ZodObject<{
61
+ type: z.ZodLiteral<"target-applied">;
62
+ applied: z.ZodOptional<z.ZodArray<z.ZodString>>;
63
+ failed: z.ZodOptional<z.ZodArray<z.ZodObject<{
64
+ id: z.ZodString;
65
+ reason: z.ZodString;
66
+ }, z.core.$strip>>>;
67
+ skewMs: z.ZodOptional<z.ZodNumber>;
68
+ }, z.core.$strip>, z.ZodObject<{
69
+ type: z.ZodLiteral<"abort">;
70
+ reason: z.ZodString;
71
+ }, z.core.$strip>], "type">;
72
+ export type JobToWorkerMessage = z.infer<typeof JobToWorkerMessageSchema>;
73
+ export declare const WorkerToJobMessageSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
74
+ type: z.ZodLiteral<"hello-ack">;
75
+ migration: z.ZodOptional<z.ZodObject<{
76
+ id: z.ZodString;
77
+ side: z.ZodLiteral<"target">;
78
+ }, z.core.$strip>>;
79
+ }, z.core.$strip>, z.ZodObject<{
80
+ type: z.ZodLiteral<"get-state">;
81
+ migrationId: z.ZodString;
82
+ }, z.core.$strip>, z.ZodObject<{
83
+ type: z.ZodLiteral<"initial-state">;
84
+ migrationId: z.ZodString;
85
+ payload: z.ZodJSONSchema;
86
+ }, z.core.$strip>, z.ZodObject<{
87
+ type: z.ZodLiteral<"cutover-ready">;
88
+ migrationId: z.ZodString;
89
+ payload: z.ZodJSONSchema;
90
+ }, z.core.$strip>, z.ZodObject<{
91
+ type: z.ZodLiteral<"cutover-apply">;
92
+ migrationId: z.ZodString;
93
+ payload: z.ZodJSONSchema;
94
+ }, z.core.$strip>, z.ZodObject<{
95
+ type: z.ZodLiteral<"abort">;
96
+ migrationId: z.ZodString;
97
+ reason: z.ZodString;
98
+ }, z.core.$strip>], "type">;
99
+ export type WorkerToJobMessage = z.infer<typeof WorkerToJobMessageSchema>;
100
+ export type ParsedFrame<T> = {
101
+ ok: true;
102
+ message: T;
103
+ } | {
104
+ ok: false;
105
+ reason: string;
106
+ };
107
+ /** What the worker does with a text frame from a job. */
108
+ export declare function parseJobToWorkerFrame(text: string): ParsedFrame<JobToWorkerMessage>;
109
+ /** What the job does with a text frame from the worker. */
110
+ export declare function parseWorkerToJobFrame(text: string): ParsedFrame<WorkerToJobMessage>;
111
+ /** A socket payload as it rides the gRPC `bytes payload` field: the UTF-8 of
112
+ * its JSON. `undefined` (a job that sent nothing) is carried as `null` so the
113
+ * bytes are always valid JSON. */
114
+ export declare function encodeMigrationPayload(payload: unknown): Uint8Array;
115
+ /** The inverse of {@link encodeMigrationPayload}. Empty bytes — a gRPC
116
+ * message with the field unset — read as `null`; bytes that are not JSON
117
+ * come back as the raw string rather than being dropped, so a foreign
118
+ * Manager's opaque payload still reaches the job. */
119
+ export declare function decodeMigrationPayload(bytes: Uint8Array | undefined): MigrationPayload;
120
+ export {};
@@ -0,0 +1,157 @@
1
+ // The job socket — the contract between a worker and a job it may hand over
2
+ // (norsk-mgr/docs/internal/design/job-migration.md §5). One module, used by
3
+ // BOTH ends: the worker's listener validates inbound frames with these
4
+ // schemas, and the SDK client (`connectMigration`) validates what the worker
5
+ // sends it. Neither side re-declares a message shape.
6
+ //
7
+ // Transport: a WebSocket on the worker host, default port 6797, reached from
8
+ // a container as `host.docker.internal`. Path `/instances/<jobId>/<role>`
9
+ // where `role` is the instance KEY (unique per migration target), both
10
+ // url-encoded. JSON text frames, each ≤ 64 KiB. `payload` is any JSON, opaque
11
+ // to the worker; on the gRPC wire it becomes the UTF-8 bytes of its
12
+ // JSON.stringify and comes back the same way.
13
+ import { z } from "zod";
14
+ export const JOB_SOCKET_DEFAULT_PORT = 6797;
15
+ export const JOB_SOCKET_MAX_FRAME_BYTES = 64 * 1024;
16
+ export const JOB_SOCKET_HELLO_TIMEOUT_MS = 5_000;
17
+ /** The name a container reaches its own worker's host under. Injected as an
18
+ * `extra_hosts` mapping on every service so it resolves on native Linux too. */
19
+ export const JOB_SOCKET_CONTAINER_HOST = "host.docker.internal";
20
+ /** The NodeInventory capability a worker advertises while its listener is up,
21
+ * so placement can require it for a handover target. */
22
+ export const JOB_SOCKET_CAPABILITY = "norsk.io/job-socket";
23
+ /** The two reserved env names a job reads. Absent when the worker runs
24
+ * without a listener (`--no-job-socket`), which is what `connectMigration`
25
+ * turns into its no-op client. */
26
+ export const JOB_SOCKET_ENV = {
27
+ url: "NORSK_WORKER_WS_URL",
28
+ token: "NORSK_WORKER_TOKEN",
29
+ };
30
+ /** Close codes the worker uses. All in the 4000–4999 application range so a
31
+ * client can tell them from transport failures. */
32
+ export const JobSocketCloseCode = {
33
+ /** A frame that breaks the protocol: unparseable, wrong side, wrong phase. */
34
+ protocolError: 4400,
35
+ /** No hello in time, a wrong token, or a path naming no instance. */
36
+ unauthorized: 4401,
37
+ /** A newer connection for the same instance replaced this one. */
38
+ replaced: 4409,
39
+ /** A frame over the size cap. */
40
+ frameTooLarge: 4413,
41
+ };
42
+ export function jobSocketPath(jobId, role) {
43
+ return `/instances/${encodeURIComponent(jobId)}/${encodeURIComponent(role)}`;
44
+ }
45
+ /** The inverse of {@link jobSocketPath}. Query strings are ignored. */
46
+ export function parseJobSocketPath(path) {
47
+ const m = /^\/instances\/([^/?#]+)\/([^/?#]+)\/?(?:[?#].*)?$/.exec(path);
48
+ if (!m?.[1] || !m[2])
49
+ return undefined;
50
+ try {
51
+ return { jobId: decodeURIComponent(m[1]), role: decodeURIComponent(m[2]) };
52
+ }
53
+ catch {
54
+ return undefined;
55
+ }
56
+ }
57
+ export function jobSocketUrl(args) {
58
+ return `ws://${args.host}:${args.port}${jobSocketPath(args.jobId, args.role)}`;
59
+ }
60
+ // ── frames ───────────────────────────────────────────────────────────────
61
+ const JsonPayload = z.json();
62
+ export const JobToWorkerMessageSchema = z.discriminatedUnion("type", [
63
+ z.object({ type: z.literal("hello"), token: z.string() }),
64
+ // The propose, answered by the SOURCE. Exactly one of these two goes
65
+ // back for a `get-state`: `state` accepts (a `null` payload is a
66
+ // perfectly good acceptance — a job with nothing to hand over still
67
+ // takes part), `state-refused` declines and the whole migration ends
68
+ // with no target ever placed. The refusal is its own frame rather than
69
+ // a field inside `payload`, so the worker can act on it without ever
70
+ // opening the opaque blob.
71
+ z.object({ type: z.literal("state"), payload: JsonPayload }),
72
+ z.object({ type: z.literal("state-refused"), reason: z.string() }),
73
+ z.object({ type: z.literal("target-ready"), payload: JsonPayload }),
74
+ z.object({ type: z.literal("source-stopped-output"), payload: JsonPayload }),
75
+ // The handover has been applied — and, past the point of no return, WHAT
76
+ // was applied. A job with more than one migration participant applies them
77
+ // in declared bands and cannot roll back once the source has stopped, so a
78
+ // participant that fails to apply is REPORTED rather than fatal. The three
79
+ // fields are envelope fields, outside any participant's opaque document, so
80
+ // the worker and the Manager can relay them without opening a blob.
81
+ //
82
+ // All three are optional: a job built before there was more than one
83
+ // participant sends the bare frame. And note that `z.object` STRIPS unknown
84
+ // keys rather than rejecting them — without these fields declared here the
85
+ // worker would accept the frame and silently drop the summary, which is why
86
+ // this and Studio's own copy
87
+ // (`norsk-studio workspaces/core/src/migration/socket-contract.ts`) have to
88
+ // move together.
89
+ z.object({
90
+ type: z.literal("target-applied"),
91
+ applied: z.array(z.string()).optional(),
92
+ failed: z.array(z.object({ id: z.string(), reason: z.string() })).optional(),
93
+ skewMs: z.number().optional(),
94
+ }),
95
+ z.object({ type: z.literal("abort"), reason: z.string() }),
96
+ ]);
97
+ export const WorkerToJobMessageSchema = z.discriminatedUnion("type", [
98
+ z.object({
99
+ type: z.literal("hello-ack"),
100
+ /** Present only when the instance was launched as a migration target. */
101
+ migration: z.object({ id: z.string(), side: z.literal("target") }).optional(),
102
+ }),
103
+ // The propose, asked of the SOURCE before any target has been placed.
104
+ z.object({ type: z.literal("get-state"), migrationId: z.string() }),
105
+ // The source's state, handed to the TARGET. Always the first frame
106
+ // after `hello-ack` when the instance was started with one, so a
107
+ // target's `ready` can depend on having applied it.
108
+ z.object({ type: z.literal("initial-state"), migrationId: z.string(), payload: JsonPayload }),
109
+ z.object({ type: z.literal("cutover-ready"), migrationId: z.string(), payload: JsonPayload }),
110
+ z.object({ type: z.literal("cutover-apply"), migrationId: z.string(), payload: JsonPayload }),
111
+ z.object({ type: z.literal("abort"), migrationId: z.string(), reason: z.string() }),
112
+ ]);
113
+ function parseFrame(schema, text) {
114
+ let raw;
115
+ try {
116
+ raw = JSON.parse(text);
117
+ }
118
+ catch {
119
+ return { ok: false, reason: "frame is not valid JSON" };
120
+ }
121
+ const parsed = schema.safeParse(raw);
122
+ if (!parsed.success)
123
+ return { ok: false, reason: `invalid frame: ${z.prettifyError(parsed.error)}` };
124
+ return { ok: true, message: parsed.data };
125
+ }
126
+ /** What the worker does with a text frame from a job. */
127
+ export function parseJobToWorkerFrame(text) {
128
+ return parseFrame(JobToWorkerMessageSchema, text);
129
+ }
130
+ /** What the job does with a text frame from the worker. */
131
+ export function parseWorkerToJobFrame(text) {
132
+ return parseFrame(WorkerToJobMessageSchema, text);
133
+ }
134
+ // ── payload ↔ gRPC bytes ─────────────────────────────────────────────────
135
+ const utf8Encoder = new TextEncoder();
136
+ const utf8Decoder = new TextDecoder();
137
+ /** A socket payload as it rides the gRPC `bytes payload` field: the UTF-8 of
138
+ * its JSON. `undefined` (a job that sent nothing) is carried as `null` so the
139
+ * bytes are always valid JSON. */
140
+ export function encodeMigrationPayload(payload) {
141
+ return utf8Encoder.encode(JSON.stringify(payload === undefined ? null : payload));
142
+ }
143
+ /** The inverse of {@link encodeMigrationPayload}. Empty bytes — a gRPC
144
+ * message with the field unset — read as `null`; bytes that are not JSON
145
+ * come back as the raw string rather than being dropped, so a foreign
146
+ * Manager's opaque payload still reaches the job. */
147
+ export function decodeMigrationPayload(bytes) {
148
+ if (!bytes || bytes.byteLength === 0)
149
+ return null;
150
+ const text = utf8Decoder.decode(bytes);
151
+ try {
152
+ return JSON.parse(text);
153
+ }
154
+ catch {
155
+ return text;
156
+ }
157
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-product-template-schema",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -26,6 +26,10 @@
26
26
  "./workdir-path": {
27
27
  "types": "./workdir-path.d.ts",
28
28
  "default": "./workdir-path.js"
29
+ },
30
+ "./migration-socket": {
31
+ "types": "./migration-socket.d.ts",
32
+ "default": "./migration-socket.js"
29
33
  }
30
34
  },
31
35
  "main": "./index.js",