@synnaxlabs/client 0.57.2 → 0.58.0

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 (83) hide show
  1. package/dist/{client-BsVW3qAW.js → client-0I0a7Hkw.js} +10121 -9064
  2. package/dist/client-0I0a7Hkw.js.map +1 -0
  3. package/dist/client.js +2061 -978
  4. package/dist/client.js.map +1 -1
  5. package/dist/src/access/policy/client.d.ts +1 -2
  6. package/dist/src/access/policy/client.d.ts.map +1 -1
  7. package/dist/src/arc/client.d.ts.map +1 -1
  8. package/dist/src/channel/client.d.ts +2 -1
  9. package/dist/src/channel/client.d.ts.map +1 -1
  10. package/dist/src/device/client.d.ts +0 -2
  11. package/dist/src/device/client.d.ts.map +1 -1
  12. package/dist/src/framer/cache/cache.d.ts.map +1 -1
  13. package/dist/src/framer/cache/reader.d.ts.map +1 -1
  14. package/dist/src/framer/cache/static.d.ts +14 -0
  15. package/dist/src/framer/cache/static.d.ts.map +1 -1
  16. package/dist/src/framer/cache/unary.d.ts +3 -0
  17. package/dist/src/framer/cache/unary.d.ts.map +1 -1
  18. package/dist/src/framer/feed.d.ts.map +1 -1
  19. package/dist/src/framer/streamer.d.ts +34 -18
  20. package/dist/src/framer/streamer.d.ts.map +1 -1
  21. package/dist/src/framer/writer.d.ts.map +1 -1
  22. package/dist/src/index.d.ts.map +1 -1
  23. package/dist/src/lineplot/actions.d.ts +48 -0
  24. package/dist/src/lineplot/actions.d.ts.map +1 -1
  25. package/dist/src/lineplot/actions.gen.d.ts +237 -0
  26. package/dist/src/lineplot/actions.gen.d.ts.map +1 -1
  27. package/dist/src/lineplot/types.gen.d.ts +87 -7
  28. package/dist/src/lineplot/types.gen.d.ts.map +1 -1
  29. package/dist/src/panel/client.d.ts +1 -2
  30. package/dist/src/panel/client.d.ts.map +1 -1
  31. package/dist/src/project/client.d.ts +1 -2
  32. package/dist/src/project/client.d.ts.map +1 -1
  33. package/dist/src/query/indexes.d.ts +11 -6
  34. package/dist/src/query/indexes.d.ts.map +1 -1
  35. package/dist/src/query/table.d.ts +12 -6
  36. package/dist/src/query/table.d.ts.map +1 -1
  37. package/dist/src/rack/client.d.ts.map +1 -1
  38. package/dist/src/ranger/client.d.ts +10 -4
  39. package/dist/src/ranger/client.d.ts.map +1 -1
  40. package/dist/src/schematic/actions.d.ts.map +1 -1
  41. package/dist/src/status/client.d.ts.map +1 -1
  42. package/dist/src/status/status.d.ts +2 -0
  43. package/dist/src/status/status.d.ts.map +1 -1
  44. package/dist/src/task/client.d.ts +19 -1
  45. package/dist/src/task/client.d.ts.map +1 -1
  46. package/dist/src/testutil/client.d.ts +2 -0
  47. package/dist/src/testutil/client.d.ts.map +1 -1
  48. package/dist/src/testutil/proxy.d.ts +8 -0
  49. package/dist/src/testutil/proxy.d.ts.map +1 -1
  50. package/dist/src/user/client.d.ts +1 -2
  51. package/dist/src/user/client.d.ts.map +1 -1
  52. package/dist/testutil.js +80 -60
  53. package/dist/testutil.js.map +1 -1
  54. package/package.json +12 -12
  55. package/src/access/policy/client.ts +0 -2
  56. package/src/arc/client.ts +2 -20
  57. package/src/channel/client.ts +12 -8
  58. package/src/device/client.ts +4 -7
  59. package/src/framer/cache/cache.ts +9 -1
  60. package/src/framer/cache/reader.ts +5 -3
  61. package/src/framer/cache/static.ts +86 -3
  62. package/src/framer/cache/unary.ts +7 -0
  63. package/src/framer/feed.ts +2 -0
  64. package/src/framer/streamer.ts +71 -6
  65. package/src/framer/writer.ts +4 -5
  66. package/src/index.ts +2 -0
  67. package/src/lineplot/actions.gen.ts +30 -0
  68. package/src/lineplot/actions.ts +14 -0
  69. package/src/lineplot/types.gen.ts +59 -21
  70. package/src/panel/client.ts +0 -2
  71. package/src/project/client.ts +0 -2
  72. package/src/query/indexes.ts +18 -7
  73. package/src/query/table.ts +52 -11
  74. package/src/rack/client.ts +5 -4
  75. package/src/ranger/client.ts +43 -12
  76. package/src/schematic/actions.ts +21 -10
  77. package/src/status/client.ts +1 -3
  78. package/src/status/status.ts +105 -1
  79. package/src/task/client.ts +57 -25
  80. package/src/testutil/client.ts +5 -1
  81. package/src/testutil/proxy.ts +59 -6
  82. package/src/user/client.ts +0 -2
  83. package/dist/client-BsVW3qAW.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"testutil.js","names":[],"sources":["../src/testutil/client.ts","../src/testutil/access.ts","../src/testutil/channels.ts","../src/testutil/connection.ts","../src/testutil/panels.ts","../src/testutil/proxy.ts","../src/testutil/query.ts","../src/testutil/telem.ts"],"sourcesContent":["// Copyright 2026 Synnax Labs, Inc.\n//\n// Use of this software is governed by the Business Source License included in the file\n// licenses/BSL.txt.\n//\n// As of the Change Date specified in that file, in accordance with the Business Source\n// License, use of this software will be governed by the Apache License, Version 2.0,\n// included in the file licenses/APL.txt.\n\nimport { TimeSpan } from \"@synnaxlabs/x\";\nimport { afterAll } from \"vitest\";\n\nimport Synnax, { type SynnaxParams } from \"@/client\";\n\nexport const TEST_CLIENT_PARAMS: SynnaxParams = {\n host: \"localhost\",\n port: 9090,\n username: \"synnax\",\n password: \"seldon\",\n retry: {\n maxRetries: 4,\n baseInterval: TimeSpan.seconds(1),\n scale: 1.5,\n },\n};\n\n// A hook registered mid-test is silently dropped by vitest, so per-client afterAll\n// calls leak clients created inside test bodies. This module-level hook registers\n// during spec collection (import time) and covers every call site.\nconst openClients: Synnax[] = [];\n// Awaited so every close finishes before vitest tears down the worker; a close\n// still in flight at teardown races its logging against the closing RPC channel.\nafterAll(async () => {\n const results = await Promise.allSettled(openClients.map((client) => client.close()));\n const failures = results.filter((result) => result.status === \"rejected\");\n if (failures.length > 0)\n throw new AggregateError(\n failures.map((failure) => failure.reason),\n \"failed to close test clients\",\n );\n});\n\n/**\n * Creates a client connected to the local test cluster. The client is closed\n * automatically once every test in the current spec file has finished, so callers do\n * not need to close it themselves.\n */\nexport const createTestClient = (params?: Partial<SynnaxParams>): Synnax => {\n const client = new Synnax({ ...TEST_CLIENT_PARAMS, ...params });\n openClients.push(client);\n return client;\n};\n","// Copyright 2026 Synnax Labs, Inc.\n//\n// Use of this software is governed by the Business Source License included in the file\n// licenses/BSL.txt.\n//\n// As of the Change Date specified in that file, in accordance with the Business Source\n// License, use of this software will be governed by the Apache License, Version 2.0,\n// included in the file licenses/APL.txt.\n\nimport { array, errors, id } from \"@synnaxlabs/x\";\n\nimport { policy } from \"@/access/policy\";\nimport { role } from \"@/access/role\";\nimport type Synnax from \"@/client\";\nimport { AccessDeniedError, NotFoundError } from \"@/errors\";\nimport { createTestClient } from \"@/testutil/client\";\nimport { user } from \"@/user\";\n\n/** Reports whether the error, or any error in its cause chain, is an access denial. */\nconst isDenial = (err: unknown): boolean =>\n AccessDeniedError.matches(err) ||\n (err instanceof Error && err.cause != null && isDenial(err.cause));\n\n/**\n * Connects as a new user and primes its policy cache. Denials are tolerated: the Core\n * refuses restricted subjects by design, and the change-stream refusal logs after\n * teardown, which vitest counts as an unhandled error.\n */\nconst connectAs = async (username: string, key: user.Key) => {\n const client = createTestClient({\n username,\n password: \"test\",\n onInternalError: (err) => {\n if (!isDenial(err)) console.error(err);\n },\n });\n try {\n await client.access.policies.retrieveForSubject(user.ontologyID(key));\n } catch (err) {\n if (!isDenial(err)) throw errors.fromUnknown(err);\n }\n return client;\n};\n\nconst createUser = async (client: Synnax) => {\n const username = id.create();\n const u = await client.users.create({\n username,\n password: \"test\",\n firstName: \"test\",\n lastName: \"test\",\n });\n return { username, key: u.key };\n};\n\n/**\n * Creates a client authenticated as a new user holding a role that carries the\n * policies. Pass several to grant different actions on different objects, which one\n * policy cannot express.\n */\nexport const createTestClientWithPolicy = async (\n client: Synnax,\n pol: policy.New | policy.New[],\n) => {\n const u = await createUser(client);\n const policies = await Promise.all(\n array.toArray(pol).map(async (p) => await client.access.policies.create(p)),\n );\n const r = await client.access.roles.create({\n name: \"test\",\n description: \"test\",\n });\n await client.ontology.addChildren(\n role.ontologyID(r.key),\n ...policies.map(({ key }) => policy.ontologyID(key)),\n );\n await client.access.roles.assign({ user: u.key, role: r.key });\n return await connectAs(u.username, u.key);\n};\n\n/** The roles the Core provisions on startup. */\nexport const BUILT_IN_ROLES = [\n \"Owner\",\n \"Engineer\",\n \"Host\",\n \"Operator\",\n \"Viewer\",\n] as const;\n\nexport type BuiltInRole = (typeof BUILT_IN_ROLES)[number];\n\n/**\n * Creates a client authenticated as a new user holding one of the Core's built-in\n * roles, for exercising a real role's permission set instead of a synthetic policy.\n * @param name - The built-in role to assign.\n * @throws {NotFoundError} if the Core provisions no role under the name.\n */\nexport const createTestClientWithRole = async (\n client: Synnax,\n name: BuiltInRole,\n): Promise<Synnax> => {\n const roles = await client.access.roles.retrieve({});\n const r = roles.find((r) => r.name === name);\n if (r == null) throw new NotFoundError(`no built-in role named ${name}`);\n const u = await createUser(client);\n await client.access.roles.assign({ user: u.key, role: r.key });\n return await connectAs(u.username, u.key);\n};\n\n/**\n * Caches one client per built-in role, so a spec covering many roles pays the user\n * creation cost once each. Construct one per spec file.\n */\nexport class RoleClients {\n private readonly client: Synnax;\n private readonly byRole = new Map<BuiltInRole, Promise<Synnax>>();\n\n constructor(client: Synnax) {\n this.client = client;\n }\n\n /** Returns a client authenticated as a user holding the role. */\n async get(name: BuiltInRole): Promise<Synnax> {\n const existing = this.byRole.get(name);\n if (existing != null) return await existing;\n const created = createTestClientWithRole(this.client, name);\n this.byRole.set(name, created);\n return await created;\n }\n}\n","// Copyright 2026 Synnax Labs, Inc.\n//\n// Use of this software is governed by the Business Source License included in the file\n// licenses/BSL.txt.\n//\n// As of the Change Date specified in that file, in accordance with the Business Source\n// License, use of this software will be governed by the Apache License, Version 2.0,\n// included in the file licenses/APL.txt.\n\nimport { DataType, id } from \"@synnaxlabs/x\";\n\nimport { type channel } from \"@/channel\";\nimport type Synnax from \"@/client\";\n\nexport const newIndexedPair = async (\n client: Synnax,\n): Promise<[channel.Channel, channel.Channel]> => {\n const index = await client.channels.create({\n leaseholder: 1,\n name: id.create(),\n dataType: DataType.TIMESTAMP,\n isIndex: true,\n });\n const data = await client.channels.create({\n leaseholder: 1,\n name: id.create(),\n dataType: DataType.FLOAT64,\n index: index.key,\n });\n return [index, data];\n};\n\nexport const newIndexedBoolPair = async (\n client: Synnax,\n): Promise<[channel.Channel, channel.Channel]> => {\n const index = await client.channels.create({\n leaseholder: 1,\n name: id.create(),\n dataType: DataType.TIMESTAMP,\n isIndex: true,\n });\n const data = await client.channels.create({\n leaseholder: 1,\n name: id.create(),\n dataType: DataType.BOOLEAN,\n index: index.key,\n });\n return [index, data];\n};\n\nexport const newVirtualChannel = async (client: Synnax): Promise<channel.Channel> => {\n const ch = await client.channels.create({\n name: id.create(),\n dataType: DataType.FLOAT64,\n virtual: true,\n });\n return ch;\n};\n\nexport const newVirtualBoolChannel = async (\n client: Synnax,\n): Promise<channel.Channel> => {\n const ch = await client.channels.create({\n name: id.create(),\n dataType: DataType.BOOLEAN,\n virtual: true,\n });\n return ch;\n};\n","// Copyright 2026 Synnax Labs, Inc.\n//\n// Use of this software is governed by the Business Source License included in the file\n// licenses/BSL.txt.\n//\n// As of the Change Date specified in that file, in accordance with the Business Source\n// License, use of this software will be governed by the Apache License, Version 2.0,\n// included in the file licenses/APL.txt.\n\nimport { TimeSpan } from \"@synnaxlabs/x\";\n\nimport { type connection } from \"@/connection\";\n\n/** Resolves once the handle's status satisfies the predicate. */\nexport const waitForStatus = async (\n handle: connection.Handle,\n predicate: (status: connection.Status) => boolean,\n timeout: TimeSpan = TimeSpan.seconds(5),\n): Promise<connection.Status> => {\n if (predicate(handle.status)) return handle.status;\n return await new Promise<connection.Status>((resolve, reject) => {\n const timer = setTimeout(() => {\n detach();\n reject(new Error(\"timed out waiting for connection status\"));\n }, timeout.milliseconds);\n const detach = handle.onChange((status) => {\n if (!predicate(status)) return;\n clearTimeout(timer);\n detach();\n resolve(status);\n });\n });\n};\n\n/**\n * Resolves once the handle's change stream is live, so every later cluster write\n * reaches the client's cache.\n */\nexport const waitForStreamLive = async (\n handle: connection.Handle,\n timeout?: TimeSpan,\n): Promise<connection.Status> =>\n await waitForStatus(\n handle,\n ({ variant, details }) => variant === \"success\" && details.streamLive,\n timeout,\n );\n","// Copyright 2026 Synnax Labs, Inc.\n//\n// Use of this software is governed by the Business Source License included in the file\n// licenses/BSL.txt.\n//\n// As of the Change Date specified in that file, in accordance with the Business Source\n// License, use of this software will be governed by the Apache License, Version 2.0,\n// included in the file licenses/APL.txt.\n\nimport { id } from \"@synnaxlabs/x\";\n\nimport type Synnax from \"@/client\";\nimport { type ontology } from \"@/ontology\";\nimport { project } from \"@/project\";\n\n/**\n * Creates a throwaway project and returns its ontology ID. Panel creation requires a\n * parent, so tests that mint panels directly parent them here.\n */\nexport const createPanelParent = async (client: Synnax): Promise<ontology.ID> => {\n const proj = await client.projects.create({\n name: `project-${id.create()}`,\n layout: {},\n });\n return project.ontologyID(proj.key);\n};\n","// Copyright 2026 Synnax Labs, Inc.\n//\n// Use of this software is governed by the Business Source License included in the file\n// licenses/BSL.txt.\n//\n// As of the Change Date specified in that file, in accordance with the Business Source\n// License, use of this software will be governed by the Apache License, Version 2.0,\n// included in the file licenses/APL.txt.\n\nimport { connect, createServer, type Socket } from \"node:net\";\n\nimport { type breaker, TimeSpan } from \"@synnaxlabs/x\";\n\nconst DEFAULT_TARGET = { host: \"localhost\", port: 9090 };\n\n/**\n * Retry policy for a client pointed at a severable proxy. A spec drives its own\n * retries, so the client's must finish well inside one assertion budget: the\n * default policy waits seconds per request and outlasts a single `expect.poll`\n * attempt, which reports as a timeout rather than the real answer.\n */\nexport const FAST_RETRY: breaker.Config = {\n baseInterval: TimeSpan.milliseconds(10),\n maxInterval: TimeSpan.milliseconds(50),\n scale: 1.5,\n};\n\nexport interface SeverableProxyTarget {\n host?: string;\n port?: number;\n}\n\nexport interface SeverableProxy {\n /** Port the proxy accepts connections on. Point the client here. */\n port: number;\n /** Drops every live connection and refuses new ones, as if the cluster died. */\n sever: () => Promise<void>;\n /** Accepts connections again on the same port. */\n restore: () => Promise<void>;\n /** Shuts the proxy down for good. */\n close: () => Promise<void>;\n}\n\n/**\n * Creates a TCP proxy in front of the live test cluster so specs can simulate\n * cluster downtime without touching the cluster itself. Connect a client to\n * the returned port, then sever() and restore() the link.\n */\nexport const createSeverableProxy = async (\n target: SeverableProxyTarget = {},\n): Promise<SeverableProxy> => {\n const { host, port } = { ...DEFAULT_TARGET, ...target };\n const sockets = new Set<Socket>();\n let severed = false;\n const server = createServer((downstream) => {\n // A connection the kernel accepted before the sever is still delivered after it,\n // and forwarding it would let a request through a severed link.\n if (severed) {\n downstream.destroy();\n return;\n }\n const upstream = connect(port, host);\n sockets.add(downstream).add(upstream);\n const destroy = (): void => {\n sockets.delete(downstream);\n sockets.delete(upstream);\n downstream.destroy();\n upstream.destroy();\n };\n downstream.on(\"error\", destroy);\n upstream.on(\"error\", destroy);\n downstream.on(\"close\", destroy);\n upstream.on(\"close\", destroy);\n downstream.pipe(upstream);\n upstream.pipe(downstream);\n });\n const listen = async (listenPort: number): Promise<void> =>\n await new Promise((resolve, reject) => {\n server.once(\"error\", reject);\n server.listen(listenPort, () => {\n server.removeListener(\"error\", reject);\n resolve();\n });\n });\n await listen(0);\n const address = server.address();\n if (address == null || typeof address === \"string\")\n throw new Error(\"proxy failed to bind a port\");\n const boundPort = address.port;\n const sever = async (): Promise<void> => {\n severed = true;\n const closed = new Promise<void>((resolve) => server.close(() => resolve()));\n sockets.forEach((socket) => socket.destroy());\n sockets.clear();\n await closed;\n };\n return {\n port: boundPort,\n sever,\n restore: async () => {\n severed = false;\n await listen(boundPort);\n },\n close: sever,\n };\n};\n","// Copyright 2026 Synnax Labs, Inc.\n//\n// Use of this software is governed by the Business Source License included in the file\n// licenses/BSL.txt.\n//\n// As of the Change Date specified in that file, in accordance with the Business Source\n// License, use of this software will be governed by the Apache License, Version 2.0,\n// included in the file licenses/APL.txt.\n\nimport { query } from \"@/query\";\n\nexport { isLive } from \"@/query/query\";\n\n/** Asserts the cached answer is live (present and not deleted) and returns it. */\nexport const expectLive = <D extends query.Data>(\n value: query.Cached<D> | undefined,\n): D => {\n if (!query.isLive<D>(value)) throw new Error(\"expected a live cached answer\");\n return value;\n};\n\n/** Asserts the cached answer is a deletion and returns it. */\nexport const expectDeleted = <D extends query.Data>(\n value: query.Cached<D> | undefined,\n): query.Deleted<D> => {\n if (!query.Deleted.matches<D>(value)) throw new Error(\"expected a deleted answer\");\n return value;\n};\n","// Copyright 2026 Synnax Labs, Inc.\n//\n// Use of this software is governed by the Business Source License included in the file\n// licenses/BSL.txt.\n//\n// As of the Change Date specified in that file, in accordance with the Business Source\n// License, use of this software will be governed by the Apache License, Version 2.0,\n// included in the file licenses/APL.txt.\n\nimport { DataType, type Series, TimeStamp } from \"@synnaxlabs/x\";\n\nimport { type framer } from \"@/framer\";\n\nexport const secondsLinspace = (start: number, n: number): TimeStamp[] =>\n Array.from({ length: n }, (_, i) => start + i).map((n) => TimeStamp.seconds(n));\n\nconst isRaw = (dt: DataType): boolean => dt.isVariable || dt.equals(DataType.UINT8);\n\n/** Narrows every numeric series to float32, anchoring bigints on an offset. Mirrors\n * the transform the visualization layer injects for WebGL rendering. */\nexport const glTransform: framer.Transform = {\n resolveDataType: (dt: DataType) => (isRaw(dt) ? dt : DataType.FLOAT32),\n convert: (series: Series, offset) => {\n if (isRaw(series.dataType)) return series;\n if (offset == null && series.dataType.usesBigInt && series.length > 0)\n offset = BigInt(series.data[0]);\n return series.convert(DataType.FLOAT32, offset);\n },\n};\n"],"mappings":";;;;;AAcA,IAAa,IAAmC;CAC9C,MAAM;CACN,MAAM;CACN,UAAU;CACV,UAAU;CACV,OAAO;EACL,YAAY;EACZ,cAAc,EAAS,QAAQ,CAAC;EAChC,OAAO;CACT;AACF,GAKM,IAAwB,CAAC;AAG/B,EAAS,YAAY;CAEnB,IAAM,KAAW,MADK,QAAQ,WAAW,EAAY,KAAK,MAAW,EAAO,MAAM,CAAC,CAAC,EAAA,CAC3D,QAAQ,MAAW,EAAO,WAAW,UAAU;CACxE,IAAI,EAAS,SAAS,GACpB,MAAU,eACR,EAAS,KAAK,MAAY,EAAQ,MAAM,GACxC,8BACF;AACJ,CAAC;AAOD,IAAa,KAAoB,MAA2C;CAC1E,IAAM,IAAS,IAAI,EAAO;EAAE,GAAG;EAAoB,GAAG;CAAO,CAAC;CAE9D,OADA,EAAY,KAAK,CAAM,GAChB;AACT,GChCM,KAAY,MAChB,EAAkB,QAAQ,CAAG,KAC5B,aAAe,SAAS,EAAI,SAAS,QAAQ,EAAS,EAAI,KAAK,GAO5D,IAAY,OAAO,GAAkB,MAAkB;CAC3D,IAAM,IAAS,EAAiB;EAC9B;EACA,UAAU;EACV,kBAAkB,MAAQ;GACxB,AAAK,EAAS,CAAG,KAAG,QAAQ,MAAM,CAAG;EACvC;CACF,CAAC;CACD,IAAI;EACF,MAAM,EAAO,OAAO,SAAS,mBAAmB,EAAgB,CAAG,CAAC;CACtE,SAAS,GAAK;EACZ,IAAI,CAAC,EAAS,CAAG,GAAG,MAAM,EAAO,YAAY,CAAG;CAClD;CACA,OAAO;AACT,GAEM,IAAa,OAAO,MAAmB;CAC3C,IAAM,IAAW,EAAG,OAAO;CAO3B,OAAO;EAAE;EAAU,MAAK,MANR,EAAO,MAAM,OAAO;GAClC;GACA,UAAU;GACV,WAAW;GACX,UAAU;EACZ,CAAC,EAAA,CACyB;CAAI;AAChC,GAOa,IAA6B,OACxC,GACA,MACG;CACH,IAAM,IAAI,MAAM,EAAW,CAAM,GAC3B,IAAW,MAAM,QAAQ,IAC7B,EAAM,QAAQ,CAAG,CAAC,CAAC,IAAI,OAAO,MAAM,MAAM,EAAO,OAAO,SAAS,OAAO,CAAC,CAAC,CAC5E,GACM,IAAI,MAAM,EAAO,OAAO,MAAM,OAAO;EACzC,MAAM;EACN,aAAa;CACf,CAAC;CAMD,OALA,MAAM,EAAO,SAAS,YACpB,EAAgB,EAAE,GAAG,GACrB,GAAG,EAAS,KAAK,EAAE,aAAU,EAAkB,CAAG,CAAC,CACrD,GACA,MAAM,EAAO,OAAO,MAAM,OAAO;EAAE,MAAM,EAAE;EAAK,MAAM,EAAE;CAAI,CAAC,GACtD,MAAM,EAAU,EAAE,UAAU,EAAE,GAAG;AAC1C,GAGa,IAAiB;CAC5B;CACA;CACA;CACA;CACA;AACF,GAUa,IAA2B,OACtC,GACA,MACoB;CAEpB,IAAM,KAAI,MADU,EAAO,OAAO,MAAM,SAAS,CAAC,CAAC,EAAA,CACnC,MAAM,MAAM,EAAE,SAAS,CAAI;CAC3C,IAAI,KAAK,MAAM,MAAM,IAAI,EAAc,0BAA0B,GAAM;CACvE,IAAM,IAAI,MAAM,EAAW,CAAM;CAEjC,OADA,MAAM,EAAO,OAAO,MAAM,OAAO;EAAE,MAAM,EAAE;EAAK,MAAM,EAAE;CAAI,CAAC,GACtD,MAAM,EAAU,EAAE,UAAU,EAAE,GAAG;AAC1C,GAMa,IAAb,MAAyB;CACvB;CACA,yBAA0B,IAAI,IAAkC;CAEhE,YAAY,GAAgB;EAC1B,KAAK,SAAS;CAChB;CAGA,MAAM,IAAI,GAAoC;EAC5C,IAAM,IAAW,KAAK,OAAO,IAAI,CAAI;EACrC,IAAI,KAAY,MAAM,OAAO,MAAM;EACnC,IAAM,IAAU,EAAyB,KAAK,QAAQ,CAAI;EAE1D,OADA,KAAK,OAAO,IAAI,GAAM,CAAO,GACtB,MAAM;CACf;AACF,GCnHa,IAAiB,OAC5B,MACgD;CAChD,IAAM,IAAQ,MAAM,EAAO,SAAS,OAAO;EACzC,aAAa;EACb,MAAM,EAAG,OAAO;EAChB,UAAU,EAAS;EACnB,SAAS;CACX,CAAC;CAOD,OAAO,CAAC,GAAO,MANI,EAAO,SAAS,OAAO;EACxC,aAAa;EACb,MAAM,EAAG,OAAO;EAChB,UAAU,EAAS;EACnB,OAAO,EAAM;CACf,CAAC,CACkB;AACrB,GAEa,IAAqB,OAChC,MACgD;CAChD,IAAM,IAAQ,MAAM,EAAO,SAAS,OAAO;EACzC,aAAa;EACb,MAAM,EAAG,OAAO;EAChB,UAAU,EAAS;EACnB,SAAS;CACX,CAAC;CAOD,OAAO,CAAC,GAAO,MANI,EAAO,SAAS,OAAO;EACxC,aAAa;EACb,MAAM,EAAG,OAAO;EAChB,UAAU,EAAS;EACnB,OAAO,EAAM;CACf,CAAC,CACkB;AACrB,GAEa,IAAoB,OAAO,MAM/B,MALU,EAAO,SAAS,OAAO;CACtC,MAAM,EAAG,OAAO;CAChB,UAAU,EAAS;CACnB,SAAS;AACX,CAAC,GAIU,IAAwB,OACnC,MAOO,MALU,EAAO,SAAS,OAAO;CACtC,MAAM,EAAG,OAAO;CAChB,UAAU,EAAS;CACnB,SAAS;AACX,CAAC,GCpDU,IAAgB,OAC3B,GACA,GACA,IAAoB,EAAS,QAAQ,CAAC,MAElC,EAAU,EAAO,MAAM,IAAU,EAAO,SACrC,MAAM,IAAI,SAA4B,GAAS,MAAW;CAC/D,IAAM,IAAQ,iBAAiB;EAE7B,AADA,EAAO,GACP,EAAO,gBAAI,MAAM,yCAAyC,CAAC;CAC7D,GAAG,EAAQ,YAAY,GACjB,IAAS,EAAO,UAAU,MAAW;EACpC,EAAU,CAAM,MACrB,aAAa,CAAK,GAClB,EAAO,GACP,EAAQ,CAAM;CAChB,CAAC;AACH,CAAC,GAOU,IAAoB,OAC/B,GACA,MAEA,MAAM,EACJ,IACC,EAAE,YAAS,iBAAc,MAAY,aAAa,EAAQ,YAC3D,CACF,GC3BW,IAAoB,OAAO,MAAyC;CAC/E,IAAM,IAAO,MAAM,EAAO,SAAS,OAAO;EACxC,MAAM,WAAW,EAAG,OAAO;EAC3B,QAAQ,CAAC;CACX,CAAC;CACD,OAAO,EAAmB,EAAK,GAAG;AACpC,GCZM,IAAiB;CAAE,MAAM;CAAa,MAAM;AAAK,GAQ1C,IAA6B;CACxC,cAAc,EAAS,aAAa,EAAE;CACtC,aAAa,EAAS,aAAa,EAAE;CACrC,OAAO;AACT,GAuBa,IAAuB,OAClC,IAA+B,CAAC,MACJ;CAC5B,IAAM,EAAE,SAAM,YAAS;EAAE,GAAG;EAAgB,GAAG;CAAO,GAChD,oBAAU,IAAI,IAAY,GAC5B,IAAU,IACR,IAAS,GAAc,MAAe;EAG1C,IAAI,GAAS;GACX,EAAW,QAAQ;GACnB;EACF;EACA,IAAM,IAAW,EAAQ,GAAM,CAAI;EACnC,EAAQ,IAAI,CAAU,CAAC,CAAC,IAAI,CAAQ;EACpC,IAAM,UAAsB;GAI1B,AAHA,EAAQ,OAAO,CAAU,GACzB,EAAQ,OAAO,CAAQ,GACvB,EAAW,QAAQ,GACnB,EAAS,QAAQ;EACnB;EAMA,AALA,EAAW,GAAG,SAAS,CAAO,GAC9B,EAAS,GAAG,SAAS,CAAO,GAC5B,EAAW,GAAG,SAAS,CAAO,GAC9B,EAAS,GAAG,SAAS,CAAO,GAC5B,EAAW,KAAK,CAAQ,GACxB,EAAS,KAAK,CAAU;CAC1B,CAAC,GACK,IAAS,OAAO,MACpB,MAAM,IAAI,SAAS,GAAS,MAAW;EAErC,AADA,EAAO,KAAK,SAAS,CAAM,GAC3B,EAAO,OAAO,SAAkB;GAE9B,AADA,EAAO,eAAe,SAAS,CAAM,GACrC,EAAQ;EACV,CAAC;CACH,CAAC;CACH,MAAM,EAAO,CAAC;CACd,IAAM,IAAU,EAAO,QAAQ;CAC/B,IAAI,KAAW,QAAQ,OAAO,KAAY,UACxC,MAAU,MAAM,6BAA6B;CAC/C,IAAM,IAAY,EAAQ,MACpB,IAAQ,YAA2B;EACvC,IAAU;EACV,IAAM,IAAS,IAAI,SAAe,MAAY,EAAO,YAAY,EAAQ,CAAC,CAAC;EAG3E,AAFA,EAAQ,SAAS,MAAW,EAAO,QAAQ,CAAC,GAC5C,EAAQ,MAAM,GACd,MAAM;CACR;CACA,OAAO;EACL,MAAM;EACN;EACA,SAAS,YAAY;GAEnB,AADA,IAAU,IACV,MAAM,EAAO,CAAS;EACxB;EACA,OAAO;CACT;AACF,GC3Fa,KACX,MACM;CACN,IAAI,CAAC,EAAgB,CAAK,GAAG,MAAU,MAAM,+BAA+B;CAC5E,OAAO;AACT,GAGa,KACX,MACqB;CACrB,IAAI,CAAA,EAAe,QAAW,CAAK,GAAG,MAAU,MAAM,2BAA2B;CACjF,OAAO;AACT,GCda,KAAmB,GAAe,MAC7C,MAAM,KAAK,EAAE,QAAQ,EAAE,IAAI,GAAG,MAAM,IAAQ,CAAC,CAAC,CAAC,KAAK,MAAM,EAAU,QAAQ,CAAC,CAAC,GAE1E,KAAS,MAA0B,EAAG,cAAc,EAAG,OAAO,EAAS,KAAK,GAIrE,IAAgC;CAC3C,kBAAkB,MAAkB,EAAM,CAAE,IAAI,IAAK,EAAS;CAC9D,UAAU,GAAgB,MACpB,EAAM,EAAO,QAAQ,IAAU,KAC/B,KAAU,QAAQ,EAAO,SAAS,cAAc,EAAO,SAAS,MAClE,IAAS,OAAO,EAAO,KAAK,EAAE,IACzB,EAAO,QAAQ,EAAS,SAAS,CAAM;AAElD"}
1
+ {"version":3,"file":"testutil.js","names":[],"sources":["../src/testutil/client.ts","../src/testutil/access.ts","../src/testutil/channels.ts","../src/testutil/connection.ts","../src/testutil/panels.ts","../src/testutil/proxy.ts","../src/testutil/query.ts","../src/testutil/telem.ts"],"sourcesContent":["// Copyright 2026 Synnax Labs, Inc.\n//\n// Use of this software is governed by the Business Source License included in the file\n// licenses/BSL.txt.\n//\n// As of the Change Date specified in that file, in accordance with the Business Source\n// License, use of this software will be governed by the Apache License, Version 2.0,\n// included in the file licenses/APL.txt.\n\nimport { TimeSpan } from \"@synnaxlabs/x\";\nimport { afterAll, vi } from \"vitest\";\n\nimport Synnax, { type SynnaxParams } from \"@/client\";\n\nexport const TEST_CLIENT_PARAMS: SynnaxParams = {\n host: \"localhost\",\n port: 9090,\n username: \"synnax\",\n password: \"seldon\",\n retry: {\n maxRetries: 4,\n baseInterval: TimeSpan.seconds(1),\n scale: 1.5,\n },\n};\n\n// A hook registered mid-test is silently dropped by vitest, so per-client afterAll\n// calls leak clients created inside test bodies. This module-level hook registers\n// during spec collection (import time) and covers every call site.\nconst openClients: Synnax[] = [];\n// Awaited so every close finishes before vitest tears down the worker; a close\n// still in flight at teardown races its logging against the closing RPC channel.\nafterAll(async () => {\n const results = await Promise.allSettled(openClients.map((client) => client.close()));\n const failures = results.filter((result) => result.status === \"rejected\");\n if (failures.length > 0)\n throw new AggregateError(\n failures.map((failure) => failure.reason),\n \"failed to close test clients\",\n );\n});\n\n/**\n * Creates a client connected to the local test cluster. The client is closed\n * automatically once every test in the current spec file has finished, so callers do\n * not need to close it themselves.\n */\nexport const createTestClient = (params?: Partial<SynnaxParams>): Synnax => {\n const client = new Synnax({ ...TEST_CLIENT_PARAMS, ...params });\n openClients.push(client);\n return client;\n};\n\n/** Spies on the client's unary send so specs can count requests on the wire. */\nexport const spyOnSend = (client: Synnax, field: \"transport\" = \"transport\") =>\n vi.spyOn(client[field].unary, \"send\");\n","// Copyright 2026 Synnax Labs, Inc.\n//\n// Use of this software is governed by the Business Source License included in the file\n// licenses/BSL.txt.\n//\n// As of the Change Date specified in that file, in accordance with the Business Source\n// License, use of this software will be governed by the Apache License, Version 2.0,\n// included in the file licenses/APL.txt.\n\nimport { array, errors, id } from \"@synnaxlabs/x\";\n\nimport { policy } from \"@/access/policy\";\nimport { role } from \"@/access/role\";\nimport type Synnax from \"@/client\";\nimport { AccessDeniedError, NotFoundError } from \"@/errors\";\nimport { createTestClient } from \"@/testutil/client\";\nimport { user } from \"@/user\";\n\n/** Reports whether the error, or any error in its cause chain, is an access denial. */\nconst isDenial = (err: unknown): boolean =>\n AccessDeniedError.matches(err) ||\n (err instanceof Error && err.cause != null && isDenial(err.cause));\n\n/**\n * Connects as a new user and primes its policy cache. Denials are tolerated: the Core\n * refuses restricted subjects by design, and the change-stream refusal logs after\n * teardown, which vitest counts as an unhandled error.\n */\nconst connectAs = async (username: string, key: user.Key) => {\n const client = createTestClient({\n username,\n password: \"test\",\n onInternalError: (err) => {\n if (!isDenial(err)) console.error(err);\n },\n });\n try {\n await client.access.policies.retrieveForSubject(user.ontologyID(key));\n } catch (err) {\n if (!isDenial(err)) throw errors.fromUnknown(err);\n }\n return client;\n};\n\nconst createUser = async (client: Synnax) => {\n const username = id.create();\n const u = await client.users.create({\n username,\n password: \"test\",\n firstName: \"test\",\n lastName: \"test\",\n });\n return { username, key: u.key };\n};\n\n/**\n * Creates a client authenticated as a new user holding a role that carries the\n * policies. Pass several to grant different actions on different objects, which one\n * policy cannot express.\n */\nexport const createTestClientWithPolicy = async (\n client: Synnax,\n pol: policy.New | policy.New[],\n) => {\n const u = await createUser(client);\n const policies = await Promise.all(\n array.toArray(pol).map(async (p) => await client.access.policies.create(p)),\n );\n const r = await client.access.roles.create({\n name: \"test\",\n description: \"test\",\n });\n await client.ontology.addChildren(\n role.ontologyID(r.key),\n ...policies.map(({ key }) => policy.ontologyID(key)),\n );\n await client.access.roles.assign({ user: u.key, role: r.key });\n return await connectAs(u.username, u.key);\n};\n\n/** The roles the Core provisions on startup. */\nexport const BUILT_IN_ROLES = [\n \"Owner\",\n \"Engineer\",\n \"Host\",\n \"Operator\",\n \"Viewer\",\n] as const;\n\nexport type BuiltInRole = (typeof BUILT_IN_ROLES)[number];\n\n/**\n * Creates a client authenticated as a new user holding one of the Core's built-in\n * roles, for exercising a real role's permission set instead of a synthetic policy.\n * @param name - The built-in role to assign.\n * @throws {NotFoundError} if the Core provisions no role under the name.\n */\nexport const createTestClientWithRole = async (\n client: Synnax,\n name: BuiltInRole,\n): Promise<Synnax> => {\n const roles = await client.access.roles.retrieve({});\n const r = roles.find((r) => r.name === name);\n if (r == null) throw new NotFoundError(`no built-in role named ${name}`);\n const u = await createUser(client);\n await client.access.roles.assign({ user: u.key, role: r.key });\n return await connectAs(u.username, u.key);\n};\n\n/**\n * Caches one client per built-in role, so a spec covering many roles pays the user\n * creation cost once each. Construct one per spec file.\n */\nexport class RoleClients {\n private readonly client: Synnax;\n private readonly byRole = new Map<BuiltInRole, Promise<Synnax>>();\n\n constructor(client: Synnax) {\n this.client = client;\n }\n\n /** Returns a client authenticated as a user holding the role. */\n async get(name: BuiltInRole): Promise<Synnax> {\n const existing = this.byRole.get(name);\n if (existing != null) return await existing;\n const created = createTestClientWithRole(this.client, name);\n this.byRole.set(name, created);\n return await created;\n }\n}\n","// Copyright 2026 Synnax Labs, Inc.\n//\n// Use of this software is governed by the Business Source License included in the file\n// licenses/BSL.txt.\n//\n// As of the Change Date specified in that file, in accordance with the Business Source\n// License, use of this software will be governed by the Apache License, Version 2.0,\n// included in the file licenses/APL.txt.\n\nimport { DataType, id } from \"@synnaxlabs/x\";\n\nimport { type channel } from \"@/channel\";\nimport type Synnax from \"@/client\";\n\nexport const newIndexedPair = async (\n client: Synnax,\n): Promise<[channel.Channel, channel.Channel]> => {\n const index = await client.channels.create({\n leaseholder: 1,\n name: id.create(),\n dataType: DataType.TIMESTAMP,\n isIndex: true,\n });\n const data = await client.channels.create({\n leaseholder: 1,\n name: id.create(),\n dataType: DataType.FLOAT64,\n index: index.key,\n });\n return [index, data];\n};\n\nexport const newIndexedBoolPair = async (\n client: Synnax,\n): Promise<[channel.Channel, channel.Channel]> => {\n const index = await client.channels.create({\n leaseholder: 1,\n name: id.create(),\n dataType: DataType.TIMESTAMP,\n isIndex: true,\n });\n const data = await client.channels.create({\n leaseholder: 1,\n name: id.create(),\n dataType: DataType.BOOLEAN,\n index: index.key,\n });\n return [index, data];\n};\n\nexport const newVirtualChannel = async (client: Synnax): Promise<channel.Channel> => {\n const ch = await client.channels.create({\n name: id.create(),\n dataType: DataType.FLOAT64,\n virtual: true,\n });\n return ch;\n};\n\nexport const newVirtualBoolChannel = async (\n client: Synnax,\n): Promise<channel.Channel> => {\n const ch = await client.channels.create({\n name: id.create(),\n dataType: DataType.BOOLEAN,\n virtual: true,\n });\n return ch;\n};\n","// Copyright 2026 Synnax Labs, Inc.\n//\n// Use of this software is governed by the Business Source License included in the file\n// licenses/BSL.txt.\n//\n// As of the Change Date specified in that file, in accordance with the Business Source\n// License, use of this software will be governed by the Apache License, Version 2.0,\n// included in the file licenses/APL.txt.\n\nimport { TimeSpan } from \"@synnaxlabs/x\";\n\nimport { type connection } from \"@/connection\";\n\n/** Resolves once the handle's status satisfies the predicate. */\nexport const waitForStatus = async (\n handle: connection.Handle,\n predicate: (status: connection.Status) => boolean,\n timeout: TimeSpan = TimeSpan.seconds(5),\n): Promise<connection.Status> => {\n if (predicate(handle.status)) return handle.status;\n return await new Promise<connection.Status>((resolve, reject) => {\n const timer = setTimeout(() => {\n detach();\n reject(new Error(\"timed out waiting for connection status\"));\n }, timeout.milliseconds);\n const detach = handle.onChange((status) => {\n if (!predicate(status)) return;\n clearTimeout(timer);\n detach();\n resolve(status);\n });\n });\n};\n\n/**\n * Resolves once the handle's change stream is live, so every later cluster write\n * reaches the client's cache.\n */\nexport const waitForStreamLive = async (\n handle: connection.Handle,\n timeout?: TimeSpan,\n): Promise<connection.Status> =>\n await waitForStatus(\n handle,\n ({ variant, details }) => variant === \"success\" && details.streamLive,\n timeout,\n );\n","// Copyright 2026 Synnax Labs, Inc.\n//\n// Use of this software is governed by the Business Source License included in the file\n// licenses/BSL.txt.\n//\n// As of the Change Date specified in that file, in accordance with the Business Source\n// License, use of this software will be governed by the Apache License, Version 2.0,\n// included in the file licenses/APL.txt.\n\nimport { id } from \"@synnaxlabs/x\";\n\nimport type Synnax from \"@/client\";\nimport { type ontology } from \"@/ontology\";\nimport { project } from \"@/project\";\n\n/**\n * Creates a throwaway project and returns its ontology ID. Panel creation requires a\n * parent, so tests that mint panels directly parent them here.\n */\nexport const createPanelParent = async (client: Synnax): Promise<ontology.ID> => {\n const proj = await client.projects.create({\n name: `project-${id.create()}`,\n layout: {},\n });\n return project.ontologyID(proj.key);\n};\n","// Copyright 2026 Synnax Labs, Inc.\n//\n// Use of this software is governed by the Business Source License included in the file\n// licenses/BSL.txt.\n//\n// As of the Change Date specified in that file, in accordance with the Business Source\n// License, use of this software will be governed by the Apache License, Version 2.0,\n// included in the file licenses/APL.txt.\n\nimport { connect, createServer, type Socket } from \"node:net\";\n\nimport { type breaker, TimeSpan } from \"@synnaxlabs/x\";\n\nconst DEFAULT_TARGET = { host: \"localhost\", port: 9090 };\n\n/**\n * Retry policy for a client pointed at a severable proxy. A spec drives its own\n * retries, so the client's must finish well inside one assertion budget: the\n * default policy waits seconds per request and outlasts a single `expect.poll`\n * attempt, which reports as a timeout rather than the real answer.\n */\nexport const FAST_RETRY: breaker.Config = {\n baseInterval: TimeSpan.milliseconds(10),\n maxInterval: TimeSpan.milliseconds(50),\n scale: 1.5,\n};\n\nexport interface SeverableProxyTarget {\n host?: string;\n port?: number;\n}\n\nexport interface SeverableProxy {\n /** Port the proxy accepts connections on. Point the client here. */\n port: number;\n /** Drops every live connection and refuses new ones, as if the cluster died. */\n sever: () => Promise<void>;\n /**\n * Silently kills every live WebSocket connection, modeling a half-open socket (NAT\n * timeout, VPN drop, laptop sleep): the cluster side is destroyed while the client\n * side is held open with its writes discarded, so no close or reset ever reaches the\n * client. New connections still forward. Returns how many connections were\n * blackholed.\n */\n blackholeStreams: () => number;\n /** Accepts connections again on the same port. */\n restore: () => Promise<void>;\n /** Shuts the proxy down for good. */\n close: () => Promise<void>;\n}\n\ninterface ProxiedPair {\n downstream: Socket;\n upstream: Socket;\n /** Whether a WebSocket upgrade was observed on this connection. */\n ws: boolean;\n}\n\n/**\n * Creates a TCP proxy in front of the live test cluster so specs can simulate\n * cluster downtime without touching the cluster itself. Connect a client to\n * the returned port, then sever() and restore() the link.\n */\nexport const createSeverableProxy = async (\n target: SeverableProxyTarget = {},\n): Promise<SeverableProxy> => {\n const { host, port } = { ...DEFAULT_TARGET, ...target };\n const pairs = new Set<ProxiedPair>();\n // Client-side sockets of blackholed connections: held open, silent, until the proxy\n // is severed or closed.\n const zombies = new Set<Socket>();\n let severed = false;\n const server = createServer((downstream) => {\n // A connection the kernel accepted before the sever is still delivered after it,\n // and forwarding it would let a request through a severed link.\n if (severed) {\n downstream.destroy();\n return;\n }\n const upstream = connect(port, host);\n const pair: ProxiedPair = { downstream, upstream, ws: false };\n pairs.add(pair);\n // The upgrade can arrive on any request of a pooled keep-alive connection, not just\n // the first, so every chunk is sniffed until one is seen.\n downstream.on(\"data\", (chunk: Buffer) => {\n if (!pair.ws && /upgrade:\\s*websocket/i.test(chunk.toString(\"latin1\")))\n pair.ws = true;\n });\n const destroy = (): void => {\n pairs.delete(pair);\n downstream.destroy();\n upstream.destroy();\n };\n downstream.on(\"error\", destroy);\n upstream.on(\"error\", destroy);\n downstream.on(\"close\", destroy);\n upstream.on(\"close\", destroy);\n downstream.pipe(upstream);\n upstream.pipe(downstream);\n });\n const listen = async (listenPort: number): Promise<void> =>\n await new Promise((resolve, reject) => {\n server.once(\"error\", reject);\n server.listen(listenPort, () => {\n server.removeListener(\"error\", reject);\n resolve();\n });\n });\n await listen(0);\n const address = server.address();\n if (address == null || typeof address === \"string\")\n throw new Error(\"proxy failed to bind a port\");\n const boundPort = address.port;\n const sever = async (): Promise<void> => {\n severed = true;\n const closed = new Promise<void>((resolve) => server.close(() => resolve()));\n pairs.forEach(({ downstream, upstream }) => {\n downstream.destroy();\n upstream.destroy();\n });\n pairs.clear();\n zombies.forEach((socket) => socket.destroy());\n zombies.clear();\n await closed;\n };\n const blackholeStreams = (): number => {\n let count = 0;\n for (const pair of [...pairs]) {\n if (!pair.ws) continue;\n count += 1;\n const { downstream, upstream } = pair;\n downstream.unpipe(upstream);\n upstream.unpipe(downstream);\n for (const socket of [downstream, upstream]) {\n socket.removeAllListeners(\"close\");\n socket.removeAllListeners(\"error\");\n socket.on(\"error\", () => {});\n }\n // Discard anything the client still writes so backpressure cannot close the\n // socket on the proxy's behalf.\n downstream.on(\"data\", () => {});\n downstream.resume();\n upstream.destroy();\n pairs.delete(pair);\n zombies.add(downstream);\n }\n return count;\n };\n return {\n port: boundPort,\n sever,\n blackholeStreams,\n restore: async () => {\n severed = false;\n await listen(boundPort);\n },\n close: sever,\n };\n};\n","// Copyright 2026 Synnax Labs, Inc.\n//\n// Use of this software is governed by the Business Source License included in the file\n// licenses/BSL.txt.\n//\n// As of the Change Date specified in that file, in accordance with the Business Source\n// License, use of this software will be governed by the Apache License, Version 2.0,\n// included in the file licenses/APL.txt.\n\nimport { query } from \"@/query\";\n\nexport { isLive } from \"@/query/query\";\n\n/** Asserts the cached answer is live (present and not deleted) and returns it. */\nexport const expectLive = <D extends query.Data>(\n value: query.Cached<D> | undefined,\n): D => {\n if (!query.isLive<D>(value)) throw new Error(\"expected a live cached answer\");\n return value;\n};\n\n/** Asserts the cached answer is a deletion and returns it. */\nexport const expectDeleted = <D extends query.Data>(\n value: query.Cached<D> | undefined,\n): query.Deleted<D> => {\n if (!query.Deleted.matches<D>(value)) throw new Error(\"expected a deleted answer\");\n return value;\n};\n","// Copyright 2026 Synnax Labs, Inc.\n//\n// Use of this software is governed by the Business Source License included in the file\n// licenses/BSL.txt.\n//\n// As of the Change Date specified in that file, in accordance with the Business Source\n// License, use of this software will be governed by the Apache License, Version 2.0,\n// included in the file licenses/APL.txt.\n\nimport { DataType, type Series, TimeStamp } from \"@synnaxlabs/x\";\n\nimport { type framer } from \"@/framer\";\n\nexport const secondsLinspace = (start: number, n: number): TimeStamp[] =>\n Array.from({ length: n }, (_, i) => start + i).map((n) => TimeStamp.seconds(n));\n\nconst isRaw = (dt: DataType): boolean => dt.isVariable || dt.equals(DataType.UINT8);\n\n/** Narrows every numeric series to float32, anchoring bigints on an offset. Mirrors\n * the transform the visualization layer injects for WebGL rendering. */\nexport const glTransform: framer.Transform = {\n resolveDataType: (dt: DataType) => (isRaw(dt) ? dt : DataType.FLOAT32),\n convert: (series: Series, offset) => {\n if (isRaw(series.dataType)) return series;\n if (offset == null && series.dataType.usesBigInt && series.length > 0)\n offset = BigInt(series.data[0]);\n return series.convert(DataType.FLOAT32, offset);\n },\n};\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synnaxlabs/client",
3
- "version": "0.57.2",
3
+ "version": "0.58.0",
4
4
  "description": "The Synnax Client Library",
5
5
  "keywords": [
6
6
  "synnax",
@@ -23,11 +23,11 @@
23
23
  "directory": "client/ts"
24
24
  },
25
25
  "dependencies": {
26
+ "@synnaxlabs/alamos": "^0.58.0",
27
+ "@synnaxlabs/freighter": "^0.58.0",
28
+ "@synnaxlabs/x": "^0.58.0",
26
29
  "immer": "^11.1.15",
27
- "zod": "^4.4.3",
28
- "@synnaxlabs/freighter": "^0.57.0",
29
- "@synnaxlabs/x": "^0.57.1",
30
- "@synnaxlabs/alamos": "^0.57.0"
30
+ "zod": "^4.5.4"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "vitest": "^4.1.10"
@@ -38,16 +38,16 @@
38
38
  }
39
39
  },
40
40
  "devDependencies": {
41
- "@vitest/coverage-v8": "^4.1.10",
42
- "@types/node": "^26.1.2",
43
- "eslint": "^10.8.0",
44
- "typescript": "^7.0.2",
45
- "vite": "^8.2.0",
46
- "vitest": "^4.1.10",
47
41
  "@synnaxlabs/eslint-config": "^0.0.0",
48
42
  "@synnaxlabs/madge": "^0.0.0",
49
43
  "@synnaxlabs/tsconfig": "^0.0.0",
50
- "@synnaxlabs/vite-plugin": "^0.0.0"
44
+ "@synnaxlabs/vite-plugin": "^0.0.0",
45
+ "@vitest/coverage-v8": "^4.1.10",
46
+ "@types/node": "^24.13.3",
47
+ "eslint": "^10.9.1",
48
+ "typescript": "^7.0.2",
49
+ "vite": "^8.2.0",
50
+ "vitest": "^4.1.10"
51
51
  },
52
52
  "type": "module",
53
53
  "files": [
@@ -132,8 +132,6 @@ export class Client extends query.Retriever<typeof retrieveMultiParamsZ, Key, Po
132
132
  return isMany ? res.policies : res.policies[0];
133
133
  }
134
134
 
135
- async delete(key: Key, opts?: query.WriteOptions): Promise<void>;
136
- async delete(keys: Key[], opts?: query.WriteOptions): Promise<void>;
137
135
  async delete(keys: Key | Key[], opts: query.WriteOptions = {}): Promise<void> {
138
136
  const keysArr = array.toArray(keys);
139
137
  const ids = ontologyID(keysArr);
package/src/arc/client.ts CHANGED
@@ -120,24 +120,6 @@ const isTaskChild = (rel: ontology.Relationship, arcKey: Key): boolean =>
120
120
  to: { type: "task" },
121
121
  });
122
122
 
123
- const taskStatusZ = z.object({ details: z.object({ task: task.keyZ }) });
124
-
125
- // Task statuses may arrive under any status key; the referenced task lives in
126
- // the details, with the "task:<key>" status key as a fallback.
127
- const affectedTaskKeys = (
128
- event: query.TableEvent<status.Key, status.Status>,
129
- ): task.Key[] | null => {
130
- const keys: task.Key[] = [];
131
- if (event.variant === "set") {
132
- const parsed = taskStatusZ.safeParse(event.value);
133
- if (parsed.success) keys.push(parsed.data.details.task);
134
- }
135
- const [type, key] = event.key.split(":");
136
- if (type === "task" && primitive.isNonZero(key) && !keys.includes(key))
137
- keys.push(key);
138
- return keys.length === 0 ? null : keys;
139
- };
140
-
141
123
  export interface ClientConfig {
142
124
  unary: UnaryClient;
143
125
  stream: StreamClient;
@@ -210,7 +192,7 @@ export class Client extends query.Retriever<
210
192
  compose: (record) => this.composeTask(record),
211
193
  equal: (a, b) => deep.equal(a.payload, b.payload),
212
194
  watch: [
213
- query.deriveWatch(this.cfg.statusStore, (event) => affectedTaskKeys(event)),
195
+ query.deriveWatch(this.cfg.statusStore, (event) => task.affectedKeys(event)),
214
196
  ],
215
197
  });
216
198
  this.taskAnswers = cache.queries<Key, task.Task | null, task.Key, task.Task>({
@@ -473,7 +455,7 @@ export class Client extends query.Retriever<
473
455
  const cachedStatus = this.cfg.statusStore.get(task.statusKey(cached.key));
474
456
  const payload = cached.payload;
475
457
  if (cachedStatus == null) return this.cfg.tasks.sugar(payload);
476
- const parsed = task.statusZ().safeParse(cachedStatus);
458
+ const parsed = task.defaultStatusZ.safeParse(cachedStatus);
477
459
  if (!parsed.success) return this.cfg.tasks.sugar(payload);
478
460
  return this.cfg.tasks.sugar({ ...payload, status: parsed.data });
479
461
  }
@@ -389,12 +389,14 @@ export class Client extends query.Retriever<
389
389
  private readonly cfg: ClientConfig;
390
390
  readonly writer: Writer;
391
391
  readonly store: query.Table<Key, Channel>;
392
+ private readonly byName: query.LookupIndex<Key, Channel>;
392
393
 
393
394
  constructor(cfg: ClientConfig) {
394
395
  const { writer, statuses, ranges, cache } = cfg;
395
396
  const statusStore = statuses.store;
396
397
  const aliasStore = ranges.aliases;
397
398
  const sugar = (payload: Payload): Channel => this.sugar(payload);
399
+ const byName = new query.LookupIndex<Key, Channel>((ch) => ch.name);
398
400
  const store = cache.createTable<Key, Channel>({
399
401
  name: "channels",
400
402
  equal: (a, b) => deep.equal(a.payload, b.payload),
@@ -406,6 +408,7 @@ export class Client extends query.Retriever<
406
408
  }),
407
409
  query.createDeleteListener(DELETE_CHANNEL_NAME, keyZ),
408
410
  ],
411
+ indexes: [byName],
409
412
  });
410
413
  const composed = cache.derive<Key, Channel, Channel>({
411
414
  name: "channel.composed",
@@ -461,6 +464,7 @@ export class Client extends query.Retriever<
461
464
  this.cfg = cfg;
462
465
  this.writer = writer;
463
466
  this.store = store;
467
+ this.byName = byName;
464
468
  }
465
469
 
466
470
  /**
@@ -541,6 +545,7 @@ export class Client extends query.Retriever<
541
545
 
542
546
  /**
543
547
  * Retrieves a channel from the database using the given key or name.
548
+ * @param params - The key or the name of the channel to retrieve.
544
549
  * @param options - Optional parameters to control the retrieval process.
545
550
  * @param options.dataTypes - Limits the query to only channels with the specified
546
551
  * data type.
@@ -571,8 +576,6 @@ export class Client extends query.Retriever<
571
576
  * @param options.notDataTypes - Limits the query to only channels without the
572
577
  * specified
573
578
  */
574
- async retrieve(params: Key | string, options?: RetrieveOptions): Promise<Channel>;
575
-
576
579
  async retrieve(
577
580
  params: PrimitiveParams | Payload[],
578
581
  options?: RetrieveOptions,
@@ -640,7 +643,7 @@ export class Client extends query.Retriever<
640
643
  }
641
644
  const names = normalized;
642
645
  await this.writer.delete({ names });
643
- const cached = this.store.get((ch) => names.includes(ch.name));
646
+ const cached = this.byName.get(names);
644
647
  if (cached.length > 0) this.store.delete(cached.map((ch) => ch.key));
645
648
  }
646
649
 
@@ -765,10 +768,11 @@ export class Client extends query.Retriever<
765
768
  const { key, rangeKey } = query;
766
769
  let ch = this.store.get(key);
767
770
  if (ch == null) {
768
- const payloads = await this.execRetrieve([key]);
769
- checkForMultipleOrNoResults("channel", key, payloads, true);
770
- ch = this.sugar(stripComposed(payloads[0]));
771
- this.store.set(key, ch);
771
+ // Through the table, so concurrent misses coalesce into one request. A plot
772
+ // panel resolves one channel per telemetry source, all in the same tick.
773
+ const fetched = await this.store.retrieve([key]);
774
+ checkForMultipleOrNoResults("channel", key, fetched, true);
775
+ [ch] = fetched;
772
776
  }
773
777
  // A cached calculated channel without a cached status is ambiguous: the
774
778
  // status may not exist, or may simply never have been fetched.
@@ -794,7 +798,7 @@ export class Client extends query.Retriever<
794
798
  const resolved: Channel[] = [];
795
799
  const missing: string[] = [];
796
800
  for (const name of new Set(names)) {
797
- const matches = this.store.get((ch) => ch.name === name);
801
+ const matches = this.byName.get(name);
798
802
  if (matches.length > 1) return null;
799
803
  if (matches.length === 0) missing.push(name);
800
804
  else resolved.push(matches[0]);
@@ -276,10 +276,6 @@ export class Client extends query.Retriever<
276
276
  return await super.retrieve(rest);
277
277
  }
278
278
 
279
- async create(device: New): Promise<Device>;
280
-
281
- async create(devices: New[]): Promise<Device[]>;
282
-
283
279
  async create<
284
280
  Properties extends z.ZodType<record.Unknown>,
285
281
  Make extends z.ZodType<string>,
@@ -424,10 +420,11 @@ export class Client extends query.Retriever<
424
420
  }
425
421
 
426
422
  private async fetchSingle(q: SingleQuery): Promise<Device> {
427
- // A status-bearing hit needs both the record and its status cached.
423
+ // Status-bearing queries bypass the table, which never holds status.
428
424
  if (q.includeStatus !== true) {
429
- const cached = this.store.get(q.key);
430
- if (cached != null) return cached;
425
+ const devices = await this.store.retrieve([q.key]);
426
+ checkForMultipleOrNoResults("Device", q, devices, true);
427
+ return devices[0];
431
428
  }
432
429
  const devices = await this.execRetrieve(q);
433
430
  checkForMultipleOrNoResults("Device", q, devices, true);
@@ -49,6 +49,7 @@ export class Cache {
49
49
  instrumentation = DEFAULT_STATIC_PROPS.instrumentation,
50
50
  transform = DEFAULT_STATIC_PROPS.transform,
51
51
  staleEntryThreshold = DEFAULT_STATIC_PROPS.staleEntryThreshold,
52
+ staleCoverageThreshold = DEFAULT_STATIC_PROPS.staleCoverageThreshold,
52
53
  } = props;
53
54
  this.props = {
54
55
  dynamicBufferSize,
@@ -56,6 +57,7 @@ export class Cache {
56
57
  instrumentation,
57
58
  transform,
58
59
  staleEntryThreshold,
60
+ staleCoverageThreshold,
59
61
  };
60
62
  this.gcInterval = setInterval(() => this.gc(), gcInterval.milliseconds);
61
63
  }
@@ -70,11 +72,17 @@ export class Cache {
70
72
  throw new UnexpectedError(`get(${key}) called on a closed telemetry cache`);
71
73
  const existing = this.cache.get(key);
72
74
  if (existing != null) return existing;
73
- const { dynamicBufferSize, transform, staleEntryThreshold } = this.props;
75
+ const {
76
+ dynamicBufferSize,
77
+ transform,
78
+ staleEntryThreshold,
79
+ staleCoverageThreshold,
80
+ } = this.props;
74
81
  const unary = new Unary({
75
82
  dynamicBufferSize,
76
83
  transform,
77
84
  staleEntryThreshold,
85
+ staleCoverageThreshold,
78
86
  instrumentation: this.props.instrumentation.child(`cache-${key}`),
79
87
  });
80
88
  this.cache.set(key, unary);
@@ -172,9 +172,11 @@ export class Reader {
172
172
  if (existing == null) grouped.set(key, [s]);
173
173
  else existing.push(s);
174
174
  });
175
- channels.forEach((key) =>
176
- cache.get(key).writeStatic(new MultiSeries(grouped.get(key) ?? [])),
177
- );
175
+ channels.forEach((key) => {
176
+ const unary = cache.get(key);
177
+ unary.writeStatic(new MultiSeries(grouped.get(key) ?? []));
178
+ unary.markFetched(gap);
179
+ });
178
180
  }
179
181
  } catch (err) {
180
182
  failure = err;
@@ -46,12 +46,19 @@ export interface StaticProps {
46
46
  * be marked as stale and subject to garbage collection.
47
47
  * @default TimeSpan.seconds(20) */
48
48
  staleEntryThreshold?: TimeSpan;
49
+ /**
50
+ * Sets how long a fetched span counts as answered before it is refetched. Bounds
51
+ * the refetch rate of spans that stay empty, as every refetch opens a short-lived
52
+ * iterator connection.
53
+ * @default TimeSpan.minutes(10) */
54
+ staleCoverageThreshold?: TimeSpan;
49
55
  }
50
56
 
51
57
  export const DEFAULT_STATIC_PROPS: Required<StaticProps> = {
52
58
  instrumentation: alamos.NOOP,
53
59
  transform: IDENTITY_TRANSFORM,
54
60
  staleEntryThreshold: TimeSpan.seconds(20),
61
+ staleCoverageThreshold: TimeSpan.minutes(10),
55
62
  };
56
63
 
57
64
  interface CacheEntry {
@@ -61,6 +68,13 @@ interface CacheEntry {
61
68
  addedAt: TimeStamp;
62
69
  }
63
70
 
71
+ interface CoveredRange {
72
+ /** The fetched span, held even when the fetch returned no samples. */
73
+ range: TimeRange;
74
+ /** When the fetch completed. Coverage answers gaps until it goes stale. */
75
+ addedAt: TimeStamp;
76
+ }
77
+
64
78
  /**
65
79
  * A cache for historical channel data that will not be modified after it is written.
66
80
  * Fetched and streamed entries are held apart because they measure position in
@@ -71,6 +85,9 @@ interface CacheEntry {
71
85
  export class Static {
72
86
  private fetched: CacheEntry[] = [];
73
87
  private streamed: CacheEntry[] = [];
88
+ // Sorted, non-overlapping spans already fetched, kept apart from the entries so a
89
+ // span that returned no samples still counts as answered and is not refetched.
90
+ private covered: CoveredRange[] = [];
74
91
  private readonly props: Required<StaticProps>;
75
92
 
76
93
  constructor(props: StaticProps) {
@@ -78,8 +95,14 @@ export class Static {
78
95
  instrumentation = DEFAULT_STATIC_PROPS.instrumentation,
79
96
  transform = DEFAULT_STATIC_PROPS.transform,
80
97
  staleEntryThreshold = DEFAULT_STATIC_PROPS.staleEntryThreshold,
98
+ staleCoverageThreshold = DEFAULT_STATIC_PROPS.staleCoverageThreshold,
81
99
  } = props;
82
- this.props = { instrumentation, transform, staleEntryThreshold };
100
+ this.props = {
101
+ instrumentation,
102
+ transform,
103
+ staleEntryThreshold,
104
+ staleCoverageThreshold,
105
+ };
83
106
  }
84
107
 
85
108
  /**
@@ -98,6 +121,36 @@ export class Static {
98
121
  this.repairIntegrity(series, entries);
99
122
  }
100
123
 
124
+ /**
125
+ * Records tr as fetched, merging it into the covered set. A covered span counts
126
+ * as answered even when empty, so it is refetched once it goes stale, not per read.
127
+ */
128
+ markFetched(tr: TimeRange): void {
129
+ if (!tr.isValid || tr.span.isZero) return;
130
+ let { start, end } = tr;
131
+ // Live merges keep the oldest stamp so a rolling read cannot refresh its own
132
+ // coverage forever. Stale records drop, so a refetch restarts their clock.
133
+ let addedAt = TimeStamp.now();
134
+ const keep: CoveredRange[] = [];
135
+ for (const c of this.covered) {
136
+ if (!this.isLive(c)) continue;
137
+ if (c.range.end.before(start) || c.range.start.after(end)) keep.push(c);
138
+ else {
139
+ if (c.range.start.before(start)) start = c.range.start;
140
+ if (c.range.end.after(end)) end = c.range.end;
141
+ if (c.addedAt.before(addedAt)) addedAt = c.addedAt;
142
+ }
143
+ }
144
+ keep.push({ range: new TimeRange(start, end), addedAt });
145
+ keep.sort((a, b) => TimeRange.sort(a.range, b.range));
146
+ this.covered = keep;
147
+ }
148
+
149
+ // Coverage answers gaps only until it goes stale. gc merely prunes dead records.
150
+ private isLive(c: CoveredRange): boolean {
151
+ return TimeStamp.since(c.addedAt).lessThan(this.props.staleCoverageThreshold);
152
+ }
153
+
101
154
  // Containment, not overlap: a fetch stamped wider than the data it returned
102
155
  // (an uncommitted tail) must not evict streamed samples it did not replace.
103
156
  private evictStreamed(written: MultiSeries): void {
@@ -125,11 +178,12 @@ export class Static {
125
178
  entries.filter((e) => e.data.timeRange.overlapsWith(tr)).map((e) => e.data);
126
179
  const fetched = overlapping(this.fetched);
127
180
  const series = [...fetched, ...overlapping(this.streamed)];
128
- if (fetched.length === 0) return { series: new MultiSeries(series), gaps: [tr] };
181
+ if (fetched.length === 0)
182
+ return { series: new MultiSeries(series), gaps: this.subtractCovered(tr) };
129
183
  const gaps: TimeRange[] = [];
130
184
  const pushGap = (start: TimeStamp, end: TimeStamp): void => {
131
185
  const gap = new TimeRange(start, end);
132
- if (gap.isValid && !gap.span.isZero) gaps.push(gap);
186
+ if (gap.isValid && !gap.span.isZero) gaps.push(...this.subtractCovered(gap));
133
187
  };
134
188
  pushGap(tr.start, fetched[0].timeRange.start);
135
189
  for (let i = 1; i < fetched.length; i++)
@@ -138,6 +192,29 @@ export class Static {
138
192
  return { series: new MultiSeries(series), gaps };
139
193
  }
140
194
 
195
+ // Removes the covered portions of gap, returning the still-unanswered remainder.
196
+ private subtractCovered(gap: TimeRange): TimeRange[] {
197
+ let remaining = [gap];
198
+ for (const c of this.covered) {
199
+ if (!this.isLive(c)) continue;
200
+ const { range } = c;
201
+ const next: TimeRange[] = [];
202
+ for (const r of remaining) {
203
+ if (!range.overlapsWith(r)) {
204
+ next.push(r);
205
+ continue;
206
+ }
207
+ const before = new TimeRange(r.start, range.start);
208
+ const after = new TimeRange(range.end, r.end);
209
+ if (before.isValid && !before.span.isZero) next.push(before);
210
+ if (after.isValid && !after.span.isZero) next.push(after);
211
+ }
212
+ remaining = next;
213
+ if (remaining.length === 0) break;
214
+ }
215
+ return remaining;
216
+ }
217
+
141
218
  /**
142
219
  * Garbage collects the cache, removing any stale entries.
143
220
  * @returns metrics about the garbage collection.
@@ -158,6 +235,11 @@ export class Static {
158
235
  });
159
236
  this.fetched = collect(this.fetched);
160
237
  this.streamed = collect(this.streamed);
238
+ // The client cannot tell "no data yet" from "no data ever" (backfill is legal),
239
+ // so stale coverage re-opens an empty span to one cheap fetch per staleness
240
+ // window instead of one per read (space-heater mode). Series purging above is not
241
+ // affected.
242
+ this.covered = this.covered.filter((c) => this.isLive(c));
161
243
  return res;
162
244
  }
163
245
 
@@ -165,6 +247,7 @@ export class Static {
165
247
  close(): void {
166
248
  this.fetched = [];
167
249
  this.streamed = [];
250
+ this.covered = [];
168
251
  }
169
252
 
170
253
  private writeOne(series: Series, entries: CacheEntry[]): void {
@@ -70,6 +70,13 @@ export class Unary {
70
70
  this.static.write(series);
71
71
  }
72
72
 
73
+ /** Records tr as fetched so reads stop reporting it as a gap even when it holds no
74
+ * samples. Call after a fetch for tr completes. */
75
+ markFetched(tr: TimeRange): void {
76
+ this.checkOpen("markFetched");
77
+ this.static.markFetched(tr);
78
+ }
79
+
73
80
  /**
74
81
  * Reads cached data overlapping the given time range. The result includes the live
75
82
  * leading buffer when the data it actually holds overlaps the range, judged by the
@@ -52,6 +52,7 @@ export class Feed {
52
52
  dynamicBufferSize,
53
53
  gcInterval,
54
54
  staleEntryThreshold,
55
+ staleCoverageThreshold,
55
56
  removalDelay,
56
57
  breaker,
57
58
  batchDebounce,
@@ -62,6 +63,7 @@ export class Feed {
62
63
  dynamicBufferSize,
63
64
  gcInterval,
64
65
  staleEntryThreshold,
66
+ staleCoverageThreshold,
65
67
  instrumentation: instrumentation?.child("cache"),
66
68
  });
67
69
  this.reader = new Reader({