target-supabase-sdk 0.2.9 → 0.2.11

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 (65) hide show
  1. package/dist/browser.d.ts +3 -4
  2. package/dist/browser.d.ts.map +1 -1
  3. package/dist/browser.js +3 -3
  4. package/dist/chunks/{enable-log-persist-CKxXtE3K.js → enable-log-persist-Cu3SGxK7.js} +212 -55
  5. package/dist/chunks/enable-log-persist-Cu3SGxK7.js.map +1 -0
  6. package/dist/chunks/{list.api-6Q4_-7oN.js → list.api-DSTOHzOC.js} +48 -13
  7. package/dist/chunks/list.api-DSTOHzOC.js.map +1 -0
  8. package/dist/chunks/{task-queue-D5uWjqVN.js → task-queue-BHvNgb-X.js} +54 -3
  9. package/dist/chunks/task-queue-BHvNgb-X.js.map +1 -0
  10. package/dist/core.api.d.ts +6 -15
  11. package/dist/core.api.d.ts.map +1 -1
  12. package/dist/core.interface.d.ts +5 -0
  13. package/dist/core.interface.d.ts.map +1 -1
  14. package/dist/core.schema.d.ts +77 -0
  15. package/dist/core.schema.d.ts.map +1 -0
  16. package/dist/link/index.d.ts +10 -0
  17. package/dist/link/index.d.ts.map +1 -0
  18. package/dist/link/link.build.d.ts +45 -0
  19. package/dist/link/link.build.d.ts.map +1 -0
  20. package/dist/list/index.d.ts +10 -0
  21. package/dist/list/index.d.ts.map +1 -0
  22. package/dist/list/list.build.d.ts +21 -0
  23. package/dist/list/list.build.d.ts.map +1 -0
  24. package/dist/node/fs/file-write-queue.d.ts +2 -0
  25. package/dist/node/fs/file-write-queue.d.ts.map +1 -0
  26. package/dist/node/fs/json-state-store.d.ts.map +1 -1
  27. package/dist/node.js +6 -6
  28. package/dist/parcel/parcel.api.d.ts +3 -3
  29. package/dist/parcel/parcel.api.d.ts.map +1 -1
  30. package/dist/preload.js +3 -2
  31. package/dist/preload.js.map +1 -1
  32. package/dist/service/config.api.d.ts.map +1 -1
  33. package/dist/service/service.api.d.ts +6 -6
  34. package/dist/service/service.api.d.ts.map +1 -1
  35. package/dist/shared/log/log-persist-errors.d.ts +4 -0
  36. package/dist/shared/log/log-persist-errors.d.ts.map +1 -0
  37. package/dist/shared/log/log-persist-logger.d.ts +1 -0
  38. package/dist/shared/log/log-persist-logger.d.ts.map +1 -1
  39. package/dist/shared/log/log-persist.config.d.ts.map +1 -1
  40. package/dist/shared/log/log-persist.d.ts +8 -1
  41. package/dist/shared/log/log-persist.d.ts.map +1 -1
  42. package/dist/shared/log/log-persist.interface.d.ts +8 -0
  43. package/dist/shared/log/log-persist.interface.d.ts.map +1 -1
  44. package/dist/task/task-post-validated.api.d.ts +2 -2
  45. package/dist/task/task-post.api.d.ts +6 -6
  46. package/dist/trigger/trigger.api.d.ts +6 -6
  47. package/package.json +3 -2
  48. package/src/browser.ts +3 -4
  49. package/src/core.api.ts +19 -28
  50. package/src/core.interface.ts +6 -0
  51. package/src/core.schema.ts +51 -0
  52. package/src/link/index.ts +10 -0
  53. package/src/link/link.build.ts +48 -0
  54. package/src/list/index.ts +13 -0
  55. package/src/list/list.build.ts +51 -0
  56. package/src/node/fs/file-write-queue.ts +15 -0
  57. package/src/node/fs/json-state-store.ts +44 -24
  58. package/src/shared/log/log-persist-errors.ts +16 -0
  59. package/src/shared/log/log-persist-logger.ts +28 -0
  60. package/src/shared/log/log-persist.config.ts +20 -0
  61. package/src/shared/log/log-persist.interface.ts +8 -0
  62. package/src/shared/log/log-persist.ts +161 -43
  63. package/dist/chunks/enable-log-persist-CKxXtE3K.js.map +0 -1
  64. package/dist/chunks/list.api-6Q4_-7oN.js.map +0 -1
  65. package/dist/chunks/task-queue-D5uWjqVN.js.map +0 -1
@@ -7,13 +7,13 @@ import type { Task } from "./task.interface";
7
7
  * @see target-supabase-sdk/node
8
8
  */
9
9
  export declare const postTaskWithValidation: (payload: {
10
- name: string;
11
10
  value: string;
11
+ name: string;
12
12
  tagList: string[];
13
13
  taskStatus: import("./task.interface").TaskStatus.OPEN | import("./task.interface").TaskStatus.TODO;
14
+ params?: unknown;
14
15
  extra?: string | undefined;
15
16
  traceId?: string | undefined;
16
17
  traceParentId?: string | null | undefined;
17
- params?: unknown;
18
18
  }) => SupabaseResponse<Task> | Promise<SupabaseResponse<Task>>;
19
19
  //# sourceMappingURL=task-post-validated.api.d.ts.map
@@ -13,22 +13,22 @@ export declare const postTaskSchema: z.ZodObject<{
13
13
  traceId: z.ZodOptional<z.ZodString>;
14
14
  traceParentId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
15
15
  }, "strip", z.ZodTypeAny, {
16
- name: string;
17
16
  value: string;
17
+ name: string;
18
18
  tagList: string[];
19
19
  taskStatus: TaskStatus.OPEN | TaskStatus.TODO;
20
+ params?: unknown;
20
21
  extra?: string | undefined;
21
22
  traceId?: string | undefined;
22
23
  traceParentId?: string | null | undefined;
23
- params?: unknown;
24
24
  }, {
25
- name: string;
26
25
  value: string;
26
+ name: string;
27
+ params?: unknown;
27
28
  tagList?: string[] | undefined;
28
29
  extra?: string | undefined;
29
30
  traceId?: string | undefined;
30
31
  traceParentId?: string | null | undefined;
31
- params?: unknown;
32
32
  taskStatus?: TaskStatus.OPEN | TaskStatus.TODO | undefined;
33
33
  }>;
34
34
  export type PostTaskPayload = z.infer<typeof postTaskSchema>;
@@ -42,13 +42,13 @@ export declare function createTaskRow({ name, value, params, taskStatus, tagList
42
42
  * Execution-time checks live in {@link TaskManager.prepareTask}.
43
43
  */
44
44
  export declare const postTask: (payload: {
45
- name: string;
46
45
  value: string;
46
+ name: string;
47
47
  tagList: string[];
48
48
  taskStatus: TaskStatus.OPEN | TaskStatus.TODO;
49
+ params?: unknown;
49
50
  extra?: string | undefined;
50
51
  traceId?: string | undefined;
51
52
  traceParentId?: string | null | undefined;
52
- params?: unknown;
53
53
  }) => SupabaseResponse<Task> | Promise<SupabaseResponse<Task>>;
54
54
  //# sourceMappingURL=task-post.api.d.ts.map
@@ -41,10 +41,10 @@ export declare const postTriggerSchema: z.ZodObject<{
41
41
  tagList: z.ZodDefault<z.ZodOptional<z.ZodArray<z.ZodString, "many">>>;
42
42
  traceId: z.ZodOptional<z.ZodString>;
43
43
  }, "strip", z.ZodTypeAny, {
44
- name: string;
44
+ status: TriggerStatus;
45
45
  value: string;
46
+ name: string;
46
47
  tagList: string[];
47
- status: TriggerStatus;
48
48
  action: {
49
49
  taskTypeKey: string;
50
50
  kind: "post_task";
@@ -59,8 +59,8 @@ export declare const postTriggerSchema: z.ZodObject<{
59
59
  };
60
60
  traceId?: string | undefined;
61
61
  }, {
62
- name: string;
63
62
  value: string;
63
+ name: string;
64
64
  action: {
65
65
  taskTypeKey: string;
66
66
  kind: "post_task";
@@ -73,9 +73,9 @@ export declare const postTriggerSchema: z.ZodObject<{
73
73
  minute: number;
74
74
  kind: "daily";
75
75
  };
76
+ status?: TriggerStatus | undefined;
76
77
  tagList?: string[] | undefined;
77
78
  traceId?: string | undefined;
78
- status?: TriggerStatus | undefined;
79
79
  }>;
80
80
  export type PostTriggerPayload = z.infer<typeof postTriggerSchema>;
81
81
  export declare const scanEnabledTriggersSchema: z.ZodObject<{
@@ -109,10 +109,10 @@ export declare const scanEnabledTriggers: (payload: {
109
109
  }) => SupabaseResponse<Trigger[]> | Promise<SupabaseResponse<Trigger[]>>;
110
110
  /** Create a trigger row (scheduler / admin). */
111
111
  export declare const postTrigger: (payload: {
112
- name: string;
112
+ status: TriggerStatus;
113
113
  value: string;
114
+ name: string;
114
115
  tagList: string[];
115
- status: TriggerStatus;
116
116
  action: {
117
117
  taskTypeKey: string;
118
118
  kind: "post_task";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "target-supabase-sdk",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
4
4
  "description": "Supabase client initializer and data access APIs",
5
5
  "license": "MIT",
6
6
  "author": "KyLoc20",
@@ -53,7 +53,8 @@
53
53
  "format": "biome check --write .",
54
54
  "format:check": "biome ci",
55
55
  "lint": "biome lint .",
56
- "verify": "pnpm typecheck && pnpm typecheck:scripts && pnpm format:check",
56
+ "verify": "pnpm typecheck && pnpm typecheck:scripts && pnpm format:check && pnpm verify:json-state-store",
57
+ "verify:json-state-store": "tsx scripts/verify-json-state-store-concurrency.ts",
57
58
  "prepare": "git config core.hooksPath .githooks",
58
59
  "worker:trigger": "tsx scripts/run-trigger-node.ts",
59
60
  "post-command": "tsx scripts/post-command.ts",
package/src/browser.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  export type { SupabaseClient } from "@supabase/supabase-js";
9
9
  export * from "./core.api";
10
10
  export * from "./core.interface";
11
+ export * from "./core.schema";
11
12
  export * from "./core.utils";
12
13
  export type {
13
14
  GetExtractionPayload,
@@ -28,10 +29,8 @@ export {
28
29
  export type { Extraction, ExtractionDetails } from "./extraction/extraction.interface";
29
30
  export { CategoryExtraction } from "./extraction/extraction.interface";
30
31
  export * from "./idea/idea.interface";
31
- export * from "./link/link.api";
32
- export * from "./link/link.interface";
33
- export * from "./list/list.api";
34
- export * from "./list/list.interface";
32
+ export * from "./link/index";
33
+ export * from "./list/index";
35
34
  export * from "./node/node.api";
36
35
  export * from "./node/node.interface";
37
36
  export type {
package/src/core.api.ts CHANGED
@@ -1,14 +1,19 @@
1
1
  import type { PostgrestFilterBuilder } from "@supabase/postgrest-js";
2
2
  import type { ZodError, ZodType } from "zod";
3
- import { generateResponse, type Target, type TargetDraft } from "./core.interface";
4
- import { BaseValidator, handleSupabaseError } from "./core.utils";
3
+ import { generateResponse, type QueryFilter, type Target, type TargetDraft } from "./core.interface";
4
+ import { type PostTargetPayload, postTargetPayloadSchema } from "./core.schema";
5
+ import { type BaseValidator, handleSupabaseError } from "./core.utils";
5
6
  import { supabase } from "./supabase";
6
7
 
7
- export interface QueryFilter {
8
- field: string;
9
- operator: "eq" | "neq" | "in";
10
- value: unknown;
11
- }
8
+ export type { QueryFilter } from "./core.interface";
9
+ export type { PostTargetPayload } from "./core.schema";
10
+ export {
11
+ formatZodError,
12
+ postTargetPayloadSchema,
13
+ queryFilterSchema,
14
+ safeParseWithSchema,
15
+ targetDraftSchema,
16
+ } from "./core.schema";
12
17
 
13
18
  export const MAX_TARGET_LIST_PAGE_SIZE = 100;
14
19
 
@@ -379,30 +384,16 @@ export const deleteTarget = async ({ id, filterList }: { id: string; filterList?
379
384
  return generateResponse.success();
380
385
  };
381
386
 
382
- export interface PostTargetPayload {
383
- name: Target["name"];
384
- category: Target["category"];
385
- value: Target["value"];
386
- tagList: Target["tagList"];
387
- extra?: Target["extra"];
388
- details?: Target["details"];
389
- }
390
-
391
- class PostTargetPayloadValidator extends BaseValidator<PostTargetPayload> {
392
- protected requiredFields: (keyof PostTargetPayload)[] = ["category", "name", "value", "tagList"];
393
- protected optionalFields: (keyof PostTargetPayload)[] = ["extra", "details"];
394
-
395
- constructor() {
396
- super();
397
- // Add custom
398
- this.addCustomValidator((_val) => {
399
- return true;
400
- });
387
+ function parsePostTargetPayload(payload: unknown): PostTargetPayload {
388
+ const result = postTargetPayloadSchema.safeParse(payload);
389
+ if (!result.success) {
390
+ throw formatZodValidationError("PostTargetPayload", result.error);
401
391
  }
392
+ return result.data;
402
393
  }
403
394
 
404
395
  export const postTarget = async (payload: PostTargetPayload) => {
405
- const validPayload = new PostTargetPayloadValidator().validate(payload);
396
+ const validPayload = parsePostTargetPayload(payload);
406
397
 
407
398
  const { data, error } = await supabase.client
408
399
  .from("target")
@@ -443,7 +434,7 @@ export const patchTarget = async ({ id, ...restPayload }: PatchTargetPayload) =>
443
434
  }
444
435
  if (fetchError) handleSupabaseError("patchTarget", fetchError, "Failed to fetch target.");
445
436
 
446
- const updatedTarget = new PostTargetPayloadValidator().validate(restPayload);
437
+ const updatedTarget = parsePostTargetPayload(restPayload);
447
438
 
448
439
  const { data, error } = await supabase.client
449
440
  .from("target")
@@ -14,6 +14,12 @@ export interface Target {
14
14
  /** Target row before persistence — no `id` or `created_at`. */
15
15
  export type TargetDraft<T extends Target> = Omit<T, "id" | "created_at">;
16
16
 
17
+ export interface QueryFilter {
18
+ field: string;
19
+ operator: "eq" | "neq" | "in";
20
+ value: unknown;
21
+ }
22
+
17
23
  export enum StatusCode {
18
24
  SUCCESS = 200,
19
25
  ERROR = 400,
@@ -0,0 +1,51 @@
1
+ import { type ZodError, type ZodType, z } from "zod";
2
+ import type { QueryFilter } from "./core.interface";
3
+
4
+ /** {@link QueryFilter} — shared by createTarget redundancy checks and message payloads. */
5
+ export const queryFilterSchema = z
6
+ .object({
7
+ field: z.string().trim().min(1),
8
+ operator: z.enum(["eq", "neq", "in"]),
9
+ value: z.any(),
10
+ })
11
+ .transform(
12
+ (row): QueryFilter => ({
13
+ field: row.field,
14
+ operator: row.operator,
15
+ value: row.value,
16
+ }),
17
+ );
18
+
19
+ /** Persisted Target create/patch body (no `id` / `created_at`). */
20
+ export const postTargetPayloadSchema = z.object({
21
+ name: z.string(),
22
+ value: z.string(),
23
+ category: z.string(),
24
+ tagList: z.array(z.string()),
25
+ extra: z.string().optional(),
26
+ details: z.unknown().optional(),
27
+ });
28
+
29
+ export type PostTargetPayload = z.infer<typeof postTargetPayloadSchema>;
30
+
31
+ /** {@link TargetDraft} — stricter trim on identity fields for draft/message boundaries. */
32
+ export const targetDraftSchema = postTargetPayloadSchema.extend({
33
+ name: z.string().trim().min(1),
34
+ value: z.string().trim().min(1),
35
+ category: z.string().trim().min(1),
36
+ });
37
+
38
+ export function formatZodError(error: ZodError): string {
39
+ return error.issues.map((issue) => issue.message).join("; ");
40
+ }
41
+
42
+ export function safeParseWithSchema<T extends ZodType>(
43
+ schema: T,
44
+ input: unknown,
45
+ ): { ok: true; data: z.infer<T> } | { ok: false; error: string } {
46
+ const result = schema.safeParse(input);
47
+ if (!result.success) {
48
+ return { ok: false, error: formatZodError(result.error) };
49
+ }
50
+ return { ok: true, data: result.data };
51
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Link domain public API — curated re-exports only.
3
+ */
4
+
5
+ export type { PostLinkCreatePayload } from "./link.api";
6
+ export { PostLinkValidator, postLinkCreate } from "./link.api";
7
+ export type { LinkTargetDraftBuildInput } from "./link.build";
8
+ export { buildLinkTargetDraft, LINK_MANIFEST_VERSION, linkTargetDraftBuildInputSchema } from "./link.build";
9
+ export type { Link, LinkDetails } from "./link.interface";
10
+ export { CategoryLink } from "./link.interface";
@@ -0,0 +1,48 @@
1
+ import { z } from "zod";
2
+ import type { TargetDraft } from "../core.interface";
3
+ import { CategoryLink, type Link } from "./link.interface";
4
+
5
+ export const LINK_MANIFEST_VERSION = 0;
6
+
7
+ /**
8
+ * Convenience input for {@link buildLinkTargetDraft} — must not include `category`.
9
+ * Also accepted in {@link buildListTargetDraft} `items` as syntactic sugar (see `list.build.ts`).
10
+ */
11
+ export interface LinkTargetDraftBuildInput<O = unknown> {
12
+ name: string;
13
+ value: string;
14
+ description: string;
15
+ preview?: string;
16
+ loaderKey: string;
17
+ tagList?: string[];
18
+ original: O;
19
+ }
20
+
21
+ /** Runtime shape check for {@link LinkTargetDraftBuildInput} — `.strict()` rejects `category` and other extra keys. */
22
+ export const linkTargetDraftBuildInputSchema = z
23
+ .object({
24
+ name: z.string(),
25
+ value: z.string(),
26
+ description: z.string(),
27
+ preview: z.string().optional(),
28
+ loaderKey: z.string(),
29
+ tagList: z.array(z.string()).optional(),
30
+ original: z.unknown(),
31
+ })
32
+ .strict();
33
+
34
+ export function buildLinkTargetDraft<O = unknown>(input: LinkTargetDraftBuildInput<O>): TargetDraft<Link> {
35
+ return {
36
+ name: input.name,
37
+ value: input.value,
38
+ category: CategoryLink.LINK,
39
+ tagList: input.tagList ?? [],
40
+ details: {
41
+ manifestVersion: LINK_MANIFEST_VERSION,
42
+ description: input.description,
43
+ preview: input.preview ?? "",
44
+ loaderKey: input.loaderKey,
45
+ original: input.original,
46
+ },
47
+ };
48
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * List domain public API — curated re-exports only.
3
+ */
4
+
5
+ export type { PostListCreatePayload } from "./list.api";
6
+ export { PostListValidator, postListCreate, postLogListCreate } from "./list.api";
7
+ export type { BuildListTargetDraftInput } from "./list.build";
8
+ export {
9
+ buildListTargetDraft,
10
+ LIST_MANIFEST_VERSION,
11
+ } from "./list.build";
12
+ export type { List, ListDetails } from "./list.interface";
13
+ export { CategoryList } from "./list.interface";
@@ -0,0 +1,51 @@
1
+ import type { TargetDraft } from "../core.interface";
2
+ import {
3
+ buildLinkTargetDraft,
4
+ type LinkTargetDraftBuildInput,
5
+ linkTargetDraftBuildInputSchema,
6
+ } from "../link/link.build";
7
+ import { CategoryList, type List, type ListDetails } from "./list.interface";
8
+
9
+ export const LIST_MANIFEST_VERSION = 0;
10
+
11
+ /**
12
+ * Input for {@link buildListTargetDraft}.
13
+ *
14
+ * `items`: most lists store Link rows. {@link LinkTargetDraftBuildInput} is syntactic
15
+ * sugar — expanded via `buildLinkTargetDraft`. Any other shape is stored as-is.
16
+ */
17
+ export type BuildListTargetDraftInput<O = unknown, TCustom = unknown> = {
18
+ loaderKey: string;
19
+ name: string;
20
+ value: string;
21
+ meta?: unknown;
22
+ preview?: string;
23
+ tagList?: string[];
24
+ items: Array<LinkTargetDraftBuildInput<O> | TCustom>;
25
+ };
26
+
27
+ function isLinkTargetDraftBuildInput(item: unknown): item is LinkTargetDraftBuildInput {
28
+ return linkTargetDraftBuildInputSchema.safeParse(item).success;
29
+ }
30
+
31
+ export function buildListTargetDraft<O = unknown, TCustom = unknown>(
32
+ input: BuildListTargetDraftInput<O, TCustom>,
33
+ ): TargetDraft<List> {
34
+ const items = input.items.map((item) => (isLinkTargetDraftBuildInput(item) ? buildLinkTargetDraft(item) : item));
35
+
36
+ const details: ListDetails = {
37
+ manifestVersion: LIST_MANIFEST_VERSION,
38
+ meta: input.meta ?? "",
39
+ preview: input.preview ?? "",
40
+ loaderKey: input.loaderKey,
41
+ items,
42
+ };
43
+
44
+ return {
45
+ name: input.name,
46
+ value: input.value,
47
+ category: CategoryList.LIST,
48
+ tagList: input.tagList ?? [],
49
+ details,
50
+ };
51
+ }
@@ -0,0 +1,15 @@
1
+ /** Per-file write queue — serializes read-modify-write for a single JSON path within one process. */
2
+ const writeChains = new Map<string, Promise<unknown>>();
3
+
4
+ export function withSerializedFileWrites<T>(filePath: string, operation: () => Promise<T>): Promise<T> {
5
+ const previous = writeChains.get(filePath) ?? Promise.resolve();
6
+ const next = previous.then(operation, operation);
7
+ writeChains.set(
8
+ filePath,
9
+ next.then(
10
+ () => undefined,
11
+ () => undefined,
12
+ ),
13
+ );
14
+ return next;
15
+ }
@@ -1,5 +1,7 @@
1
- import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
1
+ import { randomBytes } from "node:crypto";
2
+ import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
2
3
  import { dirname } from "node:path";
4
+ import { withSerializedFileWrites } from "./file-write-queue";
3
5
 
4
6
  export type JsonStatePatch<T extends object, NK extends keyof T> = Partial<Omit<T, NK>> & {
5
7
  [K in NK]?: T[K] extends object ? Partial<T[K]> : T[K];
@@ -49,9 +51,15 @@ function mergeWithNested<T extends object>(
49
51
  async function atomicWriteJson(filePath: string, data: unknown): Promise<void> {
50
52
  await mkdir(dirname(filePath), { recursive: true });
51
53
  const content = `${JSON.stringify(data, null, 2)}\n`;
52
- const tempPath = `${filePath}.${process.pid}.tmp`;
53
- await writeFile(tempPath, content, "utf8");
54
- await rename(tempPath, filePath);
54
+ JSON.parse(content);
55
+ const tempPath = `${filePath}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
56
+ try {
57
+ await writeFile(tempPath, content, "utf8");
58
+ await rename(tempPath, filePath);
59
+ } catch (error) {
60
+ await unlink(tempPath).catch(() => undefined);
61
+ throw error;
62
+ }
55
63
  }
56
64
 
57
65
  /** JSON file-backed state with optional nested key merge and atomic writes. */
@@ -70,31 +78,43 @@ export function createJsonFileStateStore<T extends object, NK extends keyof T =
70
78
  };
71
79
  }
72
80
 
73
- return {
74
- async read(): Promise<T> {
75
- try {
76
- const raw = await readFile(filePath, "utf8");
77
- const parsed = JSON.parse(raw) as Partial<T>;
78
- return mergeWithNested(defaultState, parsed, {}, nestedKeys);
79
- } catch (error) {
80
- if ((error as NodeJS.ErrnoException).code === "ENOENT") {
81
- return mergeWithNested(defaultState, {}, {}, nestedKeys);
82
- }
83
- throw error;
81
+ async function readFromDisk(): Promise<T> {
82
+ try {
83
+ const raw = await readFile(filePath, "utf8");
84
+ const parsed = JSON.parse(raw) as Partial<T>;
85
+ return mergeWithNested(defaultState, parsed, {}, nestedKeys);
86
+ } catch (error) {
87
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
88
+ return mergeWithNested(defaultState, {}, {}, nestedKeys);
84
89
  }
90
+ if (error instanceof SyntaxError) {
91
+ await unlink(filePath).catch(() => undefined);
92
+ return mergeWithNested(defaultState, {}, {}, nestedKeys);
93
+ }
94
+ throw error;
95
+ }
96
+ }
97
+
98
+ return {
99
+ read(): Promise<T> {
100
+ return readFromDisk();
85
101
  },
86
102
 
87
- async write(patch: JsonStatePatch<T, NK>): Promise<T> {
88
- const current = await this.read();
89
- const next = touchUpdatedAt(mergeWithNested(defaultState, current, patch as Partial<T>, nestedKeys));
90
- await atomicWriteJson(filePath, next);
91
- return next;
103
+ write(patch: JsonStatePatch<T, NK>): Promise<T> {
104
+ return withSerializedFileWrites(filePath, async () => {
105
+ const current = await readFromDisk();
106
+ const next = touchUpdatedAt(mergeWithNested(defaultState, current, patch as Partial<T>, nestedKeys));
107
+ await atomicWriteJson(filePath, next);
108
+ return next;
109
+ });
92
110
  },
93
111
 
94
- async reset(): Promise<T> {
95
- const next = touchUpdatedAt({ ...defaultState });
96
- await atomicWriteJson(filePath, next);
97
- return next;
112
+ reset(): Promise<T> {
113
+ return withSerializedFileWrites(filePath, async () => {
114
+ const next = touchUpdatedAt({ ...defaultState });
115
+ await atomicWriteJson(filePath, next);
116
+ return next;
117
+ });
98
118
  },
99
119
  };
100
120
  }
@@ -0,0 +1,16 @@
1
+ const PERMANENT_LOG_PERSIST_ERROR_PATTERNS = [
2
+ /not initialized/i,
3
+ /call initialize\(\) first/i,
4
+ /missing required supabase environment/i,
5
+ /preload env incomplete/i,
6
+ ];
7
+
8
+ export function formatLogPersistError(error: unknown): string {
9
+ return error instanceof Error ? error.message : String(error);
10
+ }
11
+
12
+ /** Errors that will not succeed on retry without fixing process configuration. */
13
+ export function isPermanentLogPersistError(error: unknown): boolean {
14
+ const message = formatLogPersistError(error);
15
+ return PERMANENT_LOG_PERSIST_ERROR_PATTERNS.some((pattern) => pattern.test(message));
16
+ }
@@ -8,13 +8,41 @@ export const LOG_PERSIST_INTERNAL_TOPIC = "log-persist-internal";
8
8
 
9
9
  const logger = createLogger({ module: "log-persist" });
10
10
 
11
+ let errorLogRateLimitMs = 5_000;
12
+ const lastLoggedAt = new Map<string, number>();
13
+
14
+ export function configurePersistLoggerRateLimit(ms: number): void {
15
+ errorLogRateLimitMs = Math.max(0, ms);
16
+ }
17
+
11
18
  export function isLogPersistInternalTopic(topic: string | undefined): boolean {
12
19
  return topic === LOG_PERSIST_INTERNAL_TOPIC;
13
20
  }
14
21
 
15
22
  type PersistLogData = Record<string, unknown>;
16
23
 
24
+ function shouldEmit(level: "debug" | "info" | "warn" | "error", message: string): boolean {
25
+ if (level !== "error" && level !== "warn") {
26
+ return true;
27
+ }
28
+ if (errorLogRateLimitMs <= 0) {
29
+ return true;
30
+ }
31
+
32
+ const key = `${level}:${message}`;
33
+ const now = Date.now();
34
+ const last = lastLoggedAt.get(key);
35
+ if (last != null && now - last < errorLogRateLimitMs) {
36
+ return false;
37
+ }
38
+ lastLoggedAt.set(key, now);
39
+ return true;
40
+ }
41
+
17
42
  function write(level: "debug" | "info" | "warn" | "error", message: string, data?: PersistLogData): void {
43
+ if (!shouldEmit(level, message)) {
44
+ return;
45
+ }
18
46
  logger[level](message, {
19
47
  topic: LOG_PERSIST_INTERNAL_TOPIC,
20
48
  ...(data != null ? { data } : {}),
@@ -19,6 +19,9 @@ export const DEFAULT_LOG_PERSIST_CONFIG: LogPersistConfig = {
19
19
  },
20
20
  postTimeoutMs: 30_000,
21
21
  shutdownDrainTimeoutMs: 15_000,
22
+ laneRetryIntervalMs: 5_000,
23
+ errorLogRateLimitMs: 5_000,
24
+ circuitBreakerPermanentFailureThreshold: 3,
22
25
  };
23
26
 
24
27
  export function resolveLogPersistConfig(partial?: Partial<LogPersistConfig>): LogPersistConfig {
@@ -29,6 +32,10 @@ export function resolveLogPersistConfig(partial?: Partial<LogPersistConfig>): Lo
29
32
  slow: { ...defaults.slow, ...partial?.slow },
30
33
  postTimeoutMs: partial?.postTimeoutMs ?? defaults.postTimeoutMs,
31
34
  shutdownDrainTimeoutMs: partial?.shutdownDrainTimeoutMs ?? defaults.shutdownDrainTimeoutMs,
35
+ laneRetryIntervalMs: partial?.laneRetryIntervalMs ?? defaults.laneRetryIntervalMs,
36
+ errorLogRateLimitMs: partial?.errorLogRateLimitMs ?? defaults.errorLogRateLimitMs,
37
+ circuitBreakerPermanentFailureThreshold:
38
+ partial?.circuitBreakerPermanentFailureThreshold ?? defaults.circuitBreakerPermanentFailureThreshold,
32
39
  };
33
40
  }
34
41
 
@@ -91,5 +98,18 @@ export function logPersistConfigFromEnv(env: NodeJS.ProcessEnv = process.env): L
91
98
  DEFAULT_LOG_PERSIST_CONFIG.shutdownDrainTimeoutMs,
92
99
  { env },
93
100
  ),
101
+ laneRetryIntervalMs: envMs("LOG_PERSIST_LANE_RETRY_MS", DEFAULT_LOG_PERSIST_CONFIG.laneRetryIntervalMs, {
102
+ env,
103
+ }),
104
+ errorLogRateLimitMs: envMs(
105
+ "LOG_PERSIST_ERROR_LOG_RATE_LIMIT_MS",
106
+ DEFAULT_LOG_PERSIST_CONFIG.errorLogRateLimitMs,
107
+ { env },
108
+ ),
109
+ circuitBreakerPermanentFailureThreshold: envInt(
110
+ "LOG_PERSIST_CIRCUIT_BREAKER_THRESHOLD",
111
+ DEFAULT_LOG_PERSIST_CONFIG.circuitBreakerPermanentFailureThreshold,
112
+ { env, min: 1 },
113
+ ),
94
114
  });
95
115
  }
@@ -41,6 +41,12 @@ export interface LogPersistConfig {
41
41
  postTimeoutMs: number;
42
42
  /** Graceful shutdown drain ceiling */
43
43
  shutdownDrainTimeoutMs: number;
44
+ /** Delay before medium/slow lane flush retries after failure (fast lane uses the same interval). */
45
+ laneRetryIntervalMs: number;
46
+ /** Suppress duplicate persistLogger warn/error lines with the same message within this window. */
47
+ errorLogRateLimitMs: number;
48
+ /** Disable log-persist after this many consecutive permanent flush errors. */
49
+ circuitBreakerPermanentFailureThreshold: number;
44
50
  }
45
51
 
46
52
  export interface EnableLogPersistOptions {
@@ -89,6 +95,8 @@ export interface LogPersistQueueBytesStats {
89
95
  export interface LogPersistStats {
90
96
  enabled: boolean;
91
97
  draining: boolean;
98
+ circuitBreakerTripped: boolean;
99
+ permanentFailureCount: number;
92
100
  service: string | null;
93
101
  process: string | null;
94
102
  queues: LogPersistQueueStats;