@tracemarketplace/cli 0.0.15 → 0.0.17

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 (41) hide show
  1. package/dist/api-client.d.ts +7 -0
  2. package/dist/api-client.d.ts.map +1 -1
  3. package/dist/api-client.js +79 -14
  4. package/dist/api-client.js.map +1 -1
  5. package/dist/api-client.test.d.ts +2 -0
  6. package/dist/api-client.test.d.ts.map +1 -0
  7. package/dist/api-client.test.js +34 -0
  8. package/dist/api-client.test.js.map +1 -0
  9. package/dist/cli.js +7 -8
  10. package/dist/cli.js.map +1 -1
  11. package/dist/commands/daemon.d.ts.map +1 -1
  12. package/dist/commands/daemon.js +71 -6
  13. package/dist/commands/daemon.js.map +1 -1
  14. package/dist/commands/remove-daemon.d.ts +6 -0
  15. package/dist/commands/remove-daemon.d.ts.map +1 -0
  16. package/dist/commands/remove-daemon.js +66 -0
  17. package/dist/commands/remove-daemon.js.map +1 -0
  18. package/dist/commands/submit.d.ts.map +1 -1
  19. package/dist/commands/submit.js +3 -1
  20. package/dist/commands/submit.js.map +1 -1
  21. package/dist/config.d.ts +5 -0
  22. package/dist/config.d.ts.map +1 -1
  23. package/dist/config.js +12 -0
  24. package/dist/config.js.map +1 -1
  25. package/dist/flush.d.ts +4 -1
  26. package/dist/flush.d.ts.map +1 -1
  27. package/dist/flush.js +92 -25
  28. package/dist/flush.js.map +1 -1
  29. package/dist/flush.test.js +162 -7
  30. package/dist/flush.test.js.map +1 -1
  31. package/package.json +2 -2
  32. package/src/api-client.test.ts +47 -0
  33. package/src/api-client.ts +98 -14
  34. package/src/cli.ts +8 -9
  35. package/src/commands/daemon.ts +82 -6
  36. package/src/commands/remove-daemon.ts +75 -0
  37. package/src/commands/submit.ts +4 -2
  38. package/src/config.ts +18 -0
  39. package/src/flush.test.ts +187 -6
  40. package/src/flush.ts +123 -37
  41. package/src/commands/register.ts +0 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tracemarketplace/cli",
3
- "version": "0.0.15",
3
+ "version": "0.0.17",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "tracemp": "dist/cli.js"
@@ -13,7 +13,7 @@
13
13
  "test:watch": "vitest"
14
14
  },
15
15
  "dependencies": {
16
- "@tracemarketplace/shared": "^0.0.10",
16
+ "@tracemarketplace/shared": "^0.0.11",
17
17
  "better-sqlite3": "^12.8.0",
18
18
  "chalk": "^5.3.0",
19
19
  "commander": "^12.0.0",
@@ -0,0 +1,47 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { ApiClient, ApiError } from "./api-client.js";
3
+
4
+ describe("ApiClient", () => {
5
+ const originalFetch = global.fetch;
6
+
7
+ afterEach(() => {
8
+ global.fetch = originalFetch;
9
+ vi.restoreAllMocks();
10
+ });
11
+
12
+ it("parses retry-after and json error bodies", async () => {
13
+ global.fetch = vi.fn().mockResolvedValue(
14
+ new Response(JSON.stringify({ error: "busy" }), {
15
+ status: 503,
16
+ headers: {
17
+ "content-type": "application/json",
18
+ "retry-after": "12",
19
+ },
20
+ }),
21
+ ) as typeof fetch;
22
+
23
+ const client = new ApiClient("https://example.test", "token");
24
+
25
+ await expect(client.post("/api/v1/traces/batch", {})).rejects.toEqual(
26
+ expect.objectContaining<Partial<ApiError>>({
27
+ name: "ApiError",
28
+ status: 503,
29
+ retryAfterSeconds: 12,
30
+ body: { error: "busy" },
31
+ }),
32
+ );
33
+ });
34
+
35
+ it("returns parsed json bodies on success", async () => {
36
+ global.fetch = vi.fn().mockResolvedValue(
37
+ new Response(JSON.stringify({ ok: true }), {
38
+ status: 200,
39
+ headers: { "content-type": "application/json" },
40
+ }),
41
+ ) as typeof fetch;
42
+
43
+ const client = new ApiClient("https://example.test", "token");
44
+
45
+ await expect(client.get("/health")).resolves.toEqual({ ok: true });
46
+ });
47
+ });
package/src/api-client.ts CHANGED
@@ -1,3 +1,15 @@
1
+ export class ApiError extends Error {
2
+ constructor(
3
+ message: string,
4
+ readonly status: number,
5
+ readonly body: unknown,
6
+ readonly retryAfterSeconds: number | null,
7
+ ) {
8
+ super(message);
9
+ this.name = "ApiError";
10
+ }
11
+ }
12
+
1
13
  export class ApiClient {
2
14
  constructor(
3
15
  private baseUrl: string,
@@ -5,29 +17,101 @@ export class ApiClient {
5
17
  ) {}
6
18
 
7
19
  async get(path: string): Promise<unknown> {
8
- const res = await fetch(`${this.baseUrl}${path}`, {
9
- headers: this.apiKey ? { "X-Api-Key": this.apiKey } : {},
10
- });
11
- if (!res.ok) {
12
- const text = await res.text();
13
- throw new Error(`API error ${res.status}: ${text}`);
14
- }
15
- return res.json();
20
+ return this.request("GET", path);
16
21
  }
17
22
 
18
23
  async post(path: string, body: unknown): Promise<unknown> {
24
+ return this.request("POST", path, body);
25
+ }
26
+
27
+ private async request(method: "GET" | "POST", path: string, body?: unknown): Promise<unknown> {
28
+ let reqBody: string | Uint8Array | undefined;
29
+ const extraHeaders: Record<string, string> = {};
30
+
31
+ if (method === "POST" && body !== undefined) {
32
+ const json = JSON.stringify(body);
33
+ try {
34
+ const { gzip } = await import("zlib");
35
+ const buf = await new Promise<Buffer>((resolve, reject) =>
36
+ gzip(Buffer.from(json), (err, result) => err ? reject(err) : resolve(result))
37
+ );
38
+ reqBody = new Uint8Array(buf);
39
+ extraHeaders["X-Content-Encoding"] = "gzip";
40
+ const ratio = Math.round((1 - buf.length / json.length) * 100);
41
+ console.error(`[gzip] ${Math.round(json.length / 1024)}KB → ${Math.round(buf.length / 1024)}KB (${ratio}% reduction)`);
42
+ } catch (e) {
43
+ console.error(`[gzip] compression failed, sending uncompressed: ${e}`);
44
+ reqBody = json;
45
+ }
46
+ }
47
+
19
48
  const res = await fetch(`${this.baseUrl}${path}`, {
20
- method: "POST",
49
+ method,
21
50
  headers: {
22
- "Content-Type": "application/json",
51
+ ...(method === "POST" ? { "Content-Type": "application/json" } : {}),
23
52
  ...(this.apiKey ? { "X-Api-Key": this.apiKey } : {}),
53
+ ...extraHeaders,
24
54
  },
25
- body: JSON.stringify(body),
55
+ body: reqBody as BodyInit | undefined,
26
56
  });
57
+
58
+ const parsedBody = await parseResponseBody(res);
27
59
  if (!res.ok) {
28
- const text = await res.text();
29
- throw new Error(`API error ${res.status}: ${text}`);
60
+ throw new ApiError(
61
+ `API error ${res.status}: ${formatErrorBody(parsedBody)}`,
62
+ res.status,
63
+ parsedBody,
64
+ parseRetryAfterHeader(res.headers.get("retry-after")),
65
+ );
30
66
  }
31
- return res.json();
67
+
68
+ return parsedBody;
69
+ }
70
+ }
71
+
72
+ async function parseResponseBody(res: Response): Promise<unknown> {
73
+ const text = await res.text();
74
+ if (!text) {
75
+ return null;
32
76
  }
77
+
78
+ try {
79
+ return JSON.parse(text);
80
+ } catch {
81
+ return text;
82
+ }
83
+ }
84
+
85
+ function formatErrorBody(body: unknown): string {
86
+ if (typeof body === "string") {
87
+ return body;
88
+ }
89
+
90
+ if (body && typeof body === "object") {
91
+ const error = (body as { error?: unknown }).error;
92
+ if (typeof error === "string") {
93
+ return error;
94
+ }
95
+ }
96
+
97
+ return JSON.stringify(body);
98
+ }
99
+
100
+ function parseRetryAfterHeader(value: string | null): number | null {
101
+ if (!value) {
102
+ return null;
103
+ }
104
+
105
+ const numeric = Number.parseInt(value, 10);
106
+ if (Number.isFinite(numeric)) {
107
+ return Math.max(0, numeric);
108
+ }
109
+
110
+ const dateMs = Date.parse(value);
111
+ if (Number.isNaN(dateMs)) {
112
+ return null;
113
+ }
114
+
115
+ const seconds = Math.ceil((dateMs - Date.now()) / 1000);
116
+ return Math.max(0, seconds);
33
117
  }
package/src/cli.ts CHANGED
@@ -4,7 +4,6 @@ import { dirname, join } from "path";
4
4
  import { fileURLToPath } from "url";
5
5
  import { program } from "commander";
6
6
  import { loginCommand } from "./commands/login.js";
7
- import { registerCommand } from "./commands/register.js";
8
7
  import { whoamiCommand } from "./commands/whoami.js";
9
8
  import { submitCommand } from "./commands/submit.js";
10
9
  import { statusCommand } from "./commands/status.js";
@@ -13,6 +12,7 @@ import { autoSubmitCommand } from "./commands/auto-submit.js";
13
12
  import { setupHookCommand } from "./commands/setup-hook.js";
14
13
  import { daemonCommand } from "./commands/daemon.js";
15
14
  import { removeHookCommand } from "./commands/remove-hook.js";
15
+ import { removeDaemonCommand } from "./commands/remove-daemon.js";
16
16
  import { CLI_NAME, DEFAULT_PROFILE, PROD_SERVER_URL } from "./constants.js";
17
17
 
18
18
  const profileOptionDescription = `Config profile (default: ${DEFAULT_PROFILE})`;
@@ -32,13 +32,6 @@ program
32
32
  .option("--server-url <url>", `Server URL (default prod: ${PROD_SERVER_URL})`)
33
33
  .action((opts) => loginCommand({ profile: opts.profile, serverUrl: opts.serverUrl }).catch(handleError));
34
34
 
35
- program
36
- .command("register")
37
- .description("Alias for login — signs in via browser and saves your API key")
38
- .option("--profile <name>", profileOptionDescription)
39
- .option("--server-url <url>", `Server URL (default prod: ${PROD_SERVER_URL})`)
40
- .action((opts) => registerCommand({ profile: opts.profile, serverUrl: opts.serverUrl }).catch(handleError));
41
-
42
35
  program
43
36
  .command("whoami")
44
37
  .description("Show your account info and balance")
@@ -76,7 +69,7 @@ program
76
69
  .action((opts) => historyCommand({ profile: opts.profile }).catch(handleError));
77
70
 
78
71
  program
79
- .command("auto-submit")
72
+ .command("auto-submit", { hidden: true })
80
73
  .description("Submit the current session (called by tool hooks — do not run manually)")
81
74
  .option("--profile <name>", profileOptionDescription)
82
75
  .option("--tool <tool>", "Tool that triggered the hook (claude-code|cursor|codex)")
@@ -113,6 +106,12 @@ program
113
106
  .option("--tool <tool>", "Only remove hook for specific tool (claude-code|cursor|codex)")
114
107
  .action((opts) => removeHookCommand({ tool: opts.tool }).catch(handleError));
115
108
 
109
+ program
110
+ .command("remove-daemon")
111
+ .description("Stop a running tracemp daemon")
112
+ .option("--profile <name>", profileOptionDescription)
113
+ .action((opts) => removeDaemonCommand({ profile: opts.profile }).catch(handleError));
114
+
116
115
  function handleError(e: unknown) {
117
116
  console.error((e as Error).message ?? String(e));
118
117
  process.exit(1);
@@ -6,16 +6,25 @@
6
6
  * pass and exits. `--watch` preserves the old live-watch behavior.
7
7
  *
8
8
  * State: ~/.config/tracemarketplace/daemon-state(.<profile>).json
9
- * { [filePath]: { mtime: number, size: number } }
9
+ * { [filePath]: { mtime: number; size: number } }
10
+ *
11
+ * PID: ~/.config/tracemarketplace/daemon(.<profile>).pid
10
12
  */
11
- import { readFileSync, writeFileSync, mkdirSync, statSync, existsSync } from "fs";
13
+ import { readFileSync, writeFileSync, mkdirSync, statSync, existsSync, unlinkSync } from "fs";
12
14
  import { homedir } from "os";
13
15
  import { join } from "path";
14
16
  import chalk from "chalk";
15
- import { type Config, getConfigDir, getDaemonStatePath, loadConfig, resolveProfile } from "../config.js";
17
+ import {
18
+ type Config,
19
+ getConfigDir,
20
+ getDaemonPidPath,
21
+ getDaemonStatePath,
22
+ loadConfig,
23
+ resolveProfile,
24
+ } from "../config.js";
16
25
  import { findFiles, watchDirs } from "../sessions.js";
17
26
  import { log } from "./auto-submit.js";
18
- import { loginCommandForProfile } from "../constants.js";
27
+ import { CLI_NAME, DEFAULT_PROFILE, loginCommandForProfile } from "../constants.js";
19
28
  import { buildFileSessionSource, flushTrackedSessions } from "../flush.js";
20
29
 
21
30
  type DaemonState = Record<string, { mtime: number; size: number }>;
@@ -29,6 +38,68 @@ interface DaemonOptions {
29
38
  watch?: boolean;
30
39
  }
31
40
 
41
+ function readDaemonPid(profile: string): number | null {
42
+ const pidPath = getDaemonPidPath(profile);
43
+ if (!existsSync(pidPath)) return null;
44
+
45
+ try {
46
+ const parsed = Number.parseInt(readFileSync(pidPath, "utf-8").trim(), 10);
47
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ function isProcessRunning(pid: number): boolean {
54
+ try {
55
+ process.kill(pid, 0);
56
+ return true;
57
+ } catch (err) {
58
+ return (err as NodeJS.ErrnoException).code === "EPERM";
59
+ }
60
+ }
61
+
62
+ function removeDaemonPidFile(profile: string, expectedPid?: number) {
63
+ const pidPath = getDaemonPidPath(profile);
64
+ if (!existsSync(pidPath)) return;
65
+
66
+ try {
67
+ if (expectedPid !== undefined) {
68
+ const currentPid = Number.parseInt(readFileSync(pidPath, "utf-8").trim(), 10);
69
+ if (currentPid !== expectedPid) return;
70
+ }
71
+ unlinkSync(pidPath);
72
+ } catch {}
73
+ }
74
+
75
+ function reserveDaemonPid(profile: string): () => void {
76
+ const existingPid = readDaemonPid(profile);
77
+ if (existingPid !== null) {
78
+ if (isProcessRunning(existingPid)) {
79
+ const profileArg = profile === DEFAULT_PROFILE ? "" : ` --profile ${profile}`;
80
+ throw new Error(
81
+ `Daemon already running for profile '${profile}' (pid ${existingPid}). Run: ${CLI_NAME} remove-daemon${profileArg}`
82
+ );
83
+ }
84
+ removeDaemonPidFile(profile);
85
+ } else {
86
+ removeDaemonPidFile(profile);
87
+ }
88
+
89
+ mkdirSync(getConfigDir(), { recursive: true });
90
+ writeFileSync(getDaemonPidPath(profile), `${process.pid}\n`, "utf-8");
91
+
92
+ let released = false;
93
+ const release = () => {
94
+ if (released) return;
95
+ released = true;
96
+ removeDaemonPidFile(profile, process.pid);
97
+ };
98
+
99
+ process.once("exit", release);
100
+ return release;
101
+ }
102
+
32
103
  function loadState(profile: string): DaemonState {
33
104
  try { return JSON.parse(readFileSync(getDaemonStatePath(profile), "utf-8")); } catch { return {}; }
34
105
  }
@@ -93,6 +164,7 @@ export async function daemonCommand(opts: DaemonOptions = {}): Promise<void> {
93
164
 
94
165
  const intervalSeconds = parseIntervalSeconds(opts.interval);
95
166
  let state = loadState(config.profile);
167
+ const releasePid = opts.once ? null : reserveDaemonPid(config.profile);
96
168
 
97
169
  console.log(chalk.bold("tracemp daemon starting"));
98
170
  console.log(chalk.gray(`Profile: ${config.profile}`));
@@ -102,7 +174,7 @@ export async function daemonCommand(opts: DaemonOptions = {}): Promise<void> {
102
174
  if (opts.watch) {
103
175
  console.log(chalk.gray("Mode: live watch"));
104
176
  console.log(chalk.gray("Press Ctrl+C to stop\n"));
105
- await runWatchLoop(config, tools, state);
177
+ await runWatchLoop(config, tools, state, releasePid ?? (() => {}));
106
178
  return;
107
179
  }
108
180
 
@@ -115,6 +187,7 @@ export async function daemonCommand(opts: DaemonOptions = {}): Promise<void> {
115
187
  const stop = () => {
116
188
  if (shuttingDown) return;
117
189
  shuttingDown = true;
190
+ releasePid?.();
118
191
  console.log(chalk.gray("\nDaemon stopped."));
119
192
  process.exit(0);
120
193
  };
@@ -169,7 +242,8 @@ async function runScanPass(
169
242
  async function runWatchLoop(
170
243
  config: Config,
171
244
  tools: Array<"claude_code" | "codex_cli">,
172
- state: DaemonState
245
+ state: DaemonState,
246
+ releasePid: () => void
173
247
  ): Promise<void> {
174
248
  let nextState = await runScanPass(config, tools, state, { logWhenEmpty: true });
175
249
  const stop = watchDirs(tools, async (tool, filePath) => {
@@ -180,11 +254,13 @@ async function runWatchLoop(
180
254
 
181
255
  process.on("SIGINT", () => {
182
256
  stop();
257
+ releasePid();
183
258
  console.log(chalk.gray("\nDaemon stopped."));
184
259
  process.exit(0);
185
260
  });
186
261
  process.on("SIGTERM", () => {
187
262
  stop();
263
+ releasePid();
188
264
  process.exit(0);
189
265
  });
190
266
 
@@ -0,0 +1,75 @@
1
+ import { existsSync, readFileSync, unlinkSync } from "fs";
2
+ import chalk from "chalk";
3
+ import { DEFAULT_PROFILE } from "../constants.js";
4
+ import { getDaemonPidPath, resolveProfile } from "../config.js";
5
+
6
+ interface RemoveDaemonOptions {
7
+ profile?: string;
8
+ }
9
+
10
+ function readDaemonPid(profile: string): number | null {
11
+ const pidPath = getDaemonPidPath(profile);
12
+ if (!existsSync(pidPath)) return null;
13
+
14
+ try {
15
+ const parsed = Number.parseInt(readFileSync(pidPath, "utf-8").trim(), 10);
16
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ function isProcessRunning(pid: number): boolean {
23
+ try {
24
+ process.kill(pid, 0);
25
+ return true;
26
+ } catch (err) {
27
+ return (err as NodeJS.ErrnoException).code === "EPERM";
28
+ }
29
+ }
30
+
31
+ function removePidFile(profile: string) {
32
+ const pidPath = getDaemonPidPath(profile);
33
+ if (!existsSync(pidPath)) return;
34
+ try {
35
+ unlinkSync(pidPath);
36
+ } catch {}
37
+ }
38
+
39
+ async function waitForExit(pid: number, timeoutMs = 3000): Promise<boolean> {
40
+ const deadline = Date.now() + timeoutMs;
41
+ while (Date.now() < deadline) {
42
+ if (!isProcessRunning(pid)) {
43
+ return true;
44
+ }
45
+ await new Promise((resolve) => setTimeout(resolve, 100));
46
+ }
47
+ return !isProcessRunning(pid);
48
+ }
49
+
50
+ export async function removeDaemonCommand(opts: RemoveDaemonOptions = {}): Promise<void> {
51
+ const profile = resolveProfile(opts.profile);
52
+ const pid = readDaemonPid(profile);
53
+ const profileLabel = profile === DEFAULT_PROFILE ? "default profile" : `profile '${profile}'`;
54
+
55
+ if (pid === null) {
56
+ console.log(chalk.gray(`No tracemp daemon found for ${profileLabel}.`));
57
+ return;
58
+ }
59
+
60
+ if (!isProcessRunning(pid)) {
61
+ removePidFile(profile);
62
+ console.log(chalk.gray(`Removed stale daemon PID file for ${profileLabel}.`));
63
+ return;
64
+ }
65
+
66
+ process.kill(pid, "SIGTERM");
67
+
68
+ if (await waitForExit(pid)) {
69
+ removePidFile(profile);
70
+ console.log(chalk.green(`✓ Stopped tracemp daemon for ${profileLabel}`));
71
+ return;
72
+ }
73
+
74
+ console.log(chalk.yellow(`Sent SIGTERM to tracemp daemon for ${profileLabel} (pid ${pid}), but it is still running.`));
75
+ }
@@ -271,10 +271,12 @@ export async function submitCommand(opts: SubmitOptions): Promise<void> {
271
271
  const uploadSpinner = ora(`Submitting ${readyChunkCount} finalized chunk(s) to ${config.profile}...`).start();
272
272
 
273
273
  try {
274
+ const readySessions = plannedSessions.filter((session) => session.readyChunks > 0);
275
+ const prefetchedTraces = new Map(readySessions.map((s) => [`${s.source.tool}:${s.source.locator}`, s.trace]));
274
276
  const result = await flushTrackedSessions(
275
277
  config,
276
- plannedSessions.map((session) => session.source),
277
- { includeIdleTracked: false }
278
+ readySessions.map((session) => session.source),
279
+ { includeIdleTracked: false, prefetchedTraces }
278
280
  );
279
281
 
280
282
  uploadSpinner.stop();
package/src/config.ts CHANGED
@@ -29,6 +29,10 @@ export interface SessionUploadState {
29
29
  lastSeenTurnCount: number;
30
30
  lastActivityAt: string | null;
31
31
  lastFlushedTurnId: string | null;
32
+ // Async confirmation tracking
33
+ confirmedChunkIndex: number; // all chunks below this index are confirmed in DB
34
+ confirmedOpenChunkStartTurn: number; // openChunkStartTurn to restore if re-submitting
35
+ unconfirmedSince: string | null; // ISO timestamp when chunks first went unconfirmed
32
36
  }
33
37
 
34
38
  export interface SubmitState {
@@ -60,6 +64,10 @@ export function getDaemonStatePath(profile?: string): string {
60
64
  return join(getConfigDir(), `daemon-state${profileSuffix(profile)}.json`);
61
65
  }
62
66
 
67
+ export function getDaemonPidPath(profile?: string): string {
68
+ return join(getConfigDir(), `daemon${profileSuffix(profile)}.pid`);
69
+ }
70
+
63
71
  export function resolveProfile(profile?: string): string {
64
72
  if (profile) return normalizeProfile(profile);
65
73
  if (process.env.TRACEMP_PROFILE) return normalizeProfile(process.env.TRACEMP_PROFILE);
@@ -261,3 +269,13 @@ function isSessionUploadState(value: unknown): value is SessionUploadState {
261
269
  && (value.lastActivityAt === null || typeof value.lastActivityAt === "string")
262
270
  && (value.lastFlushedTurnId === null || typeof value.lastFlushedTurnId === "string");
263
271
  }
272
+
273
+ export function migrateSessionUploadState(value: SessionUploadState): SessionUploadState {
274
+ return {
275
+ ...value,
276
+ // Backward compat: existing sessions assume all submitted chunks are confirmed
277
+ confirmedChunkIndex: value.confirmedChunkIndex ?? value.nextChunkIndex,
278
+ confirmedOpenChunkStartTurn: value.confirmedOpenChunkStartTurn ?? value.openChunkStartTurn,
279
+ unconfirmedSince: value.unconfirmedSince ?? null,
280
+ };
281
+ }