@segmentstream-cloud/app 0.5.0 → 0.6.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.
package/README.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @segmentstream-cloud/app
2
2
 
3
+ The ontology client uses snake_case entity fields and action names. Its TypeScript
4
+ options remain camelCase; it maps `orderBy`, `isNull`, `startsWith`, and
5
+ `validateOnly` to the generated REST contract and returns pagination/history
6
+ metadata as camelCase. Queries using multiword filter operators lazily read and
7
+ cache the ontology catalog so JSON property contents remain unchanged.
8
+
3
9
  Sign-in, users, permissions, the ontology client and warehouse data access for
4
10
  SegmentStream workspace apps built with Next.js (App Router, version 16 or later) and
5
11
  React 19. The app's users sign in with an email code, or a sign-in link the first time,
@@ -111,7 +117,7 @@ authUserId?, organizationId, workspaceId }`. `id` is the person's ontology `user
111
117
  or use another email.
112
118
  - `getUser()` returns the same, or `null` when signed out; it throws
113
119
  `SegmentStreamApiError` with `code: "not_invited"` for people without an invitation.
114
- - `can("lead:changeStatus")` is `true` when the user's permissions cover the
120
+ - `can("lead:change_status")` is `true` when the user's permissions cover the
115
121
  permission. Roles may hold wildcards: `lead:*`, `*:read`, `*:*`. `isAllowed` and
116
122
  `grants` apply the same rule to any list of permissions.
117
123
  - `signOut()` ends the session, clears its cookies and redirects to `/sign-in` (or
@@ -126,22 +132,22 @@ user in Server Functions and Route Handlers too; do not rely on the proxy alone.
126
132
  (no Tailwind). Every class starts with `ss-`; the `--ss-*` custom properties on
127
133
  `:root` set colours, radius and font.
128
134
 
129
- | Export | Does |
130
- | -------------------------------------- | ------------------------------------------------------------------------------- |
131
- | `<SignIn />` | Email, then a six-digit code or a sign-in link; resend after 30 seconds, errors |
132
- | `useSignIn()` | The same sign-in without markup, for the app's own form |
133
- | `<SignedIn>`, `<SignedOut>` | Render children by session state |
134
- | `<Can permission="lead:changeStatus">` | Renders children when the user holds the permission; `fallback` otherwise |
135
- | `<UserMenu />` | Name, email, roles and sign-out |
136
- | `useUser()` | `{ status, user, error }` from `/api/ontology/me`, loaded once per session |
137
- | `usePermissions()` | `{ isLoading, roles, permissions, can(permission) }` |
138
- | `useSession()`, `signOut()` | The Neon Auth session in the browser; sign-out from the browser |
135
+ | Export | Does |
136
+ | --------------------------------------- | ------------------------------------------------------------------------------- |
137
+ | `<SignIn />` | Email, then a six-digit code or a sign-in link; resend after 30 seconds, errors |
138
+ | `useSignIn()` | The same sign-in without markup, for the app's own form |
139
+ | `<SignedIn>`, `<SignedOut>` | Render children by session state |
140
+ | `<Can permission="lead:change_status">` | Renders children when the user holds the permission; `fallback` otherwise |
141
+ | `<UserMenu />` | Name, email, roles and sign-out |
142
+ | `useUser()` | `{ status, user, error }` from `/api/ontology/me`, loaded once per session |
143
+ | `usePermissions()` | `{ isLoading, roles, permissions, can(permission) }` |
144
+ | `useSession()`, `signOut()` | The Neon Auth session in the browser; sign-out from the browser |
139
145
 
140
146
  `<Can>` only hides controls: the ontology service checks every read and action. A
141
147
  Server Component may wrap server-rendered children in `<Can>`:
142
148
 
143
149
  ```tsx
144
- <Can permission="lead:changeStatus" fallback={lead.status}>
150
+ <Can permission="lead:change_status" fallback={lead.status}>
145
151
  <ChangeStatusForm lead={lead.id} />
146
152
  </Can>
147
153
  ```
@@ -254,7 +260,7 @@ Records, action inputs and action results are typed from the ontology:
254
260
  ```ts
255
261
  const { items, nextCursor } = await segmentstream.ontology.lead.find({
256
262
  where: { status: { in: ["new", "contacted"] } },
257
- orderBy: [{ field: "createdAt", direction: "desc" }],
263
+ orderBy: [{ field: "created_at", direction: "desc" }],
258
264
  include: { owner: true },
259
265
  limit: 20,
260
266
  });
@@ -263,8 +269,11 @@ const history = await segmentstream.ontology.lead.history(id, { limit: 50 });
263
269
  const page = await segmentstream.ontology.lead.query({ where: { email: { startsWith: "ada" } } });
264
270
 
265
271
  const input = { lead: id, status: "contacted" } as const;
266
- const check = await segmentstream.ontology.lead.actions.changeStatus(input, { validateOnly: true });
267
- const { result, validation, edits } = await segmentstream.ontology.lead.actions.changeStatus(input);
272
+ const check = await segmentstream.ontology.lead.actions.change_status(input, {
273
+ validateOnly: true,
274
+ });
275
+ const { result, validation, edits } =
276
+ await segmentstream.ontology.lead.actions.change_status(input);
268
277
  ```
269
278
 
270
279
  - `find` sends the query as URL parameters, `query` as a JSON body; both return
@@ -272,7 +281,7 @@ const { result, validation, edits } = await segmentstream.ontology.lead.actions.
272
281
  - `actions.<name>(input, { validateOnly })` posts `{ input, options }` and returns
273
282
  `{ result, validation, edits }`. Generated actions are there too
274
283
  (`actions.create`, `update`, `delete`), and `action(name, input)` runs any action by
275
- name, such as `upsertByEmail`.
284
+ name, such as `upsert_by_email`.
276
285
  - Failures throw `SegmentStreamApiError` with the service's `code` (`forbidden`,
277
286
  `not_found`, `invalid_input`, `action_rejected`, `conflict`, `not_invited`), `status`,
278
287
  `detail`, `requestId` and, for invalid input, `issues`.
package/dist/errors.js CHANGED
@@ -35,7 +35,11 @@ export function apiErrorFrom(status, payload, fallbackDetail) {
35
35
  code: typeof body.error === "string" ? body.error : `http_${status}`,
36
36
  status,
37
37
  detail: typeof body.detail === "string" ? body.detail : fallbackDetail,
38
- requestId: typeof body.requestId === "string" ? body.requestId : undefined,
38
+ requestId: typeof body.request_id === "string"
39
+ ? body.request_id
40
+ : typeof body.requestId === "string"
41
+ ? body.requestId
42
+ : undefined,
39
43
  retryable: typeof body.retryable === "boolean" ? body.retryable : status >= 500 || status === 429,
40
44
  issues: issuesFrom(body.issues),
41
45
  });
@@ -0,0 +1,33 @@
1
+ import type { OntologyQuery, Where } from "./ontology.js";
2
+ interface CatalogProperty {
3
+ readonly name: string;
4
+ readonly type: string;
5
+ }
6
+ interface CatalogRelationship {
7
+ readonly name: string;
8
+ readonly to: string;
9
+ readonly inverse: string;
10
+ readonly cardinality: string;
11
+ }
12
+ interface CatalogEntity {
13
+ readonly name: string;
14
+ readonly properties: ReadonlyArray<CatalogProperty>;
15
+ readonly relationships: ReadonlyArray<CatalogRelationship>;
16
+ }
17
+ export interface WireCatalog {
18
+ readonly hash?: string;
19
+ readonly entities: ReadonlyArray<CatalogEntity>;
20
+ }
21
+ /** Catalog metadata distinguishes relationship filters from opaque JSON property values. */
22
+ export declare function encodeWhere(input: {
23
+ readonly where: Where | undefined;
24
+ readonly entity: string;
25
+ readonly getCatalog: () => Promise<WireCatalog>;
26
+ }): Promise<Where | undefined>;
27
+ export declare function queryToWire(query: OntologyQuery): unknown;
28
+ export declare function responseFromWire(input: {
29
+ readonly method: "GET" | "POST";
30
+ readonly path: string;
31
+ readonly payload: unknown;
32
+ }): unknown;
33
+ export {};
@@ -0,0 +1,161 @@
1
+ const FILTER_OPERATORS = [
2
+ "eq",
3
+ "ne",
4
+ "in",
5
+ "isNull",
6
+ "contains",
7
+ "startsWith",
8
+ "gt",
9
+ "gte",
10
+ "lt",
11
+ "lte",
12
+ "between",
13
+ ];
14
+ const SYSTEM_FIELDS = ["id", "created_at", "updated_at", "created_by", "updated_by"];
15
+ function isRecord(value) {
16
+ return value !== null && typeof value === "object" && !Array.isArray(value);
17
+ }
18
+ function needsOperatorEncoding(value) {
19
+ if (value === null || typeof value !== "object") {
20
+ return false;
21
+ }
22
+ for (const [key, child] of Object.entries(value)) {
23
+ if (key === "isNull" || key === "startsWith") {
24
+ return true;
25
+ }
26
+ if (needsOperatorEncoding(child)) {
27
+ return true;
28
+ }
29
+ }
30
+ return false;
31
+ }
32
+ /** Catalog metadata distinguishes relationship filters from opaque JSON property values. */
33
+ export async function encodeWhere(input) {
34
+ const { where, entity, getCatalog } = input;
35
+ if (where === undefined || !needsOperatorEncoding(where)) {
36
+ return where;
37
+ }
38
+ const catalog = await getCatalog();
39
+ return whereFromCatalog({ where, entity, catalog });
40
+ }
41
+ function whereFromCatalog(input) {
42
+ const { where, entity, catalog } = input;
43
+ const definition = catalog.entities.find((candidate) => candidate.name === entity);
44
+ const encodedWhere = Object.create(null);
45
+ for (const [field, condition] of Object.entries(where)) {
46
+ if (field === "and" || field === "or") {
47
+ const conditions = condition;
48
+ encodedWhere[field] = conditions.map((child) => whereFromCatalog({ where: child, entity, catalog }));
49
+ continue;
50
+ }
51
+ if (field === "not") {
52
+ encodedWhere[field] = whereFromCatalog({ where: condition, entity, catalog });
53
+ continue;
54
+ }
55
+ const relationship = relationshipTarget({ field, entity, definition, catalog });
56
+ if (relationship !== undefined && isRecord(condition)) {
57
+ encodedWhere[field] = relationshipFilterToWire({ condition, relationship, catalog });
58
+ continue;
59
+ }
60
+ encodedWhere[field] = fieldFilterToWire({ field, condition, definition });
61
+ }
62
+ return encodedWhere;
63
+ }
64
+ function relationshipTarget(input) {
65
+ const { field, entity, definition, catalog } = input;
66
+ const forward = definition?.relationships.find((candidate) => candidate.name === field);
67
+ if (forward !== undefined) {
68
+ return { entity: forward.to, many: forward.cardinality === "many" };
69
+ }
70
+ for (const candidate of catalog.entities) {
71
+ const inverse = candidate.relationships.find((relationship) => relationship.to === entity && relationship.inverse === field);
72
+ if (inverse !== undefined) {
73
+ return { entity: candidate.name, many: true };
74
+ }
75
+ }
76
+ return undefined;
77
+ }
78
+ function relationshipFilterToWire(input) {
79
+ const { condition, relationship, catalog } = input;
80
+ const entity = relationship.entity;
81
+ if (!relationship.many) {
82
+ return whereFromCatalog({ where: condition, entity, catalog });
83
+ }
84
+ const encodedQuantifiers = Object.create(null);
85
+ for (const [quantifier, child] of Object.entries(condition)) {
86
+ encodedQuantifiers[quantifier] = whereFromCatalog({ where: child, entity, catalog });
87
+ }
88
+ return encodedQuantifiers;
89
+ }
90
+ function isGeneratedField(field, definition) {
91
+ if (SYSTEM_FIELDS.includes(field)) {
92
+ return true;
93
+ }
94
+ return (definition?.relationships.some((relationship) => relationship.cardinality === "one" && `${relationship.name}_id` === field) ?? false);
95
+ }
96
+ function fieldFilterToWire(input) {
97
+ const { field, condition, definition } = input;
98
+ if (!isRecord(condition)) {
99
+ return condition;
100
+ }
101
+ const property = definition?.properties.find((candidate) => candidate.name === field);
102
+ if (property === undefined && !isGeneratedField(field, definition)) {
103
+ return condition;
104
+ }
105
+ const operatorFilter = Object.keys(condition).every((operator) => FILTER_OPERATORS.includes(operator));
106
+ if (property?.type === "json" && !operatorFilter) {
107
+ return condition;
108
+ }
109
+ const encodedFilter = Object.create(null);
110
+ for (const [operator, operand] of Object.entries(condition)) {
111
+ encodedFilter[operatorToWire(operator)] = operand;
112
+ }
113
+ return encodedFilter;
114
+ }
115
+ function operatorToWire(operator) {
116
+ if (operator === "isNull") {
117
+ return "is_null";
118
+ }
119
+ if (operator === "startsWith") {
120
+ return "starts_with";
121
+ }
122
+ return operator;
123
+ }
124
+ export function queryToWire(query) {
125
+ const { orderBy, where, ...rest } = query;
126
+ return {
127
+ ...rest,
128
+ ...(orderBy === undefined ? {} : { order_by: orderBy }),
129
+ ...(where === undefined ? {} : { where }),
130
+ };
131
+ }
132
+ function isPagedRoute(method, route) {
133
+ if (route.endsWith("/history")) {
134
+ return true;
135
+ }
136
+ if (method === "POST") {
137
+ return route.endsWith("/query");
138
+ }
139
+ const segments = route.split("/").filter(Boolean);
140
+ return segments.length === 1;
141
+ }
142
+ function historyEntryFromWire(item) {
143
+ const { record_id: recordId, action_id: actionId, principal_id: principalId, ...entry } = item;
144
+ return { ...entry, recordId, actionId, principalId };
145
+ }
146
+ export function responseFromWire(input) {
147
+ const { method, path, payload } = input;
148
+ if (!isRecord(payload)) {
149
+ return payload;
150
+ }
151
+ const route = path.split("?")[0] ?? "";
152
+ if (!isPagedRoute(method, route) || !Object.hasOwn(payload, "next_cursor")) {
153
+ return payload;
154
+ }
155
+ const { next_cursor: nextCursor, ...page } = payload;
156
+ if (!route.endsWith("/history") || !Array.isArray(page.items)) {
157
+ return { ...page, nextCursor };
158
+ }
159
+ const items = page.items.map(historyEntryFromWire);
160
+ return { ...page, nextCursor, items };
161
+ }
@@ -222,7 +222,7 @@ export interface OntologyTransport {
222
222
  }
223
223
  /**
224
224
  * Entities and actions exist only in the app's types, so the client resolves them by name when
225
- * they are used: `ontology.lead.actions.changeStatus(…)` posts to `lead/actions/changeStatus`.
225
+ * they are used: `ontology.lead.actions.change_status(…)` posts to `lead/actions/change_status`.
226
226
  */
227
227
  export declare function createOntologyClient<O>(transport: OntologyTransport, aggregateTransport?: OntologyTransport): OntologyClient<O>;
228
228
  /** `find` sends the query as URL parameters: `where` as JSON, `orderBy` as `field:direction`. */
package/dist/ontology.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { SegmentStreamApiError, apiErrorFrom, readJson } from "./errors.js";
2
+ import { encodeWhere, queryToWire, responseFromWire } from "./ontology-wire.js";
2
3
  const ENTITY_NAME = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/;
3
- const ACTION_NAME = /^[a-z][a-zA-Z0-9]*$/;
4
+ const ACTION_NAME = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/;
4
5
  /** Names a caller may probe on any object; never an entity or an action. */
5
6
  const RESERVED = new Set([
6
7
  "then",
@@ -17,10 +18,11 @@ const RESERVED = new Set([
17
18
  ]);
18
19
  /**
19
20
  * Entities and actions exist only in the app's types, so the client resolves them by name when
20
- * they are used: `ontology.lead.actions.changeStatus(…)` posts to `lead/actions/changeStatus`.
21
+ * they are used: `ontology.lead.actions.change_status(…)` posts to `lead/actions/change_status`.
21
22
  */
22
23
  export function createOntologyClient(transport, aggregateTransport) {
23
24
  const entities = new Map();
25
+ const catalogReader = createCatalogReader(transport);
24
26
  return new Proxy(Object.create(null), {
25
27
  get(_target, property) {
26
28
  if (typeof property !== "string" || RESERVED.has(property) || !ENTITY_NAME.test(property)) {
@@ -29,10 +31,11 @@ export function createOntologyClient(transport, aggregateTransport) {
29
31
  let client = entities.get(property);
30
32
  if (client === undefined) {
31
33
  client = {
32
- ...entityClient(property, transport),
34
+ ...entityClient(property, catalogReader.transport, catalogReader.getCatalog),
33
35
  aggregate: (request, options = {}) => {
34
- if (!aggregateTransport)
36
+ if (!aggregateTransport) {
35
37
  throw new TypeError("An aggregate transport is required.");
38
+ }
36
39
  return send(aggregateTransport, "POST", `/${property}/aggregate`, request, options.signal);
37
40
  },
38
41
  };
@@ -42,14 +45,45 @@ export function createOntologyClient(transport, aggregateTransport) {
42
45
  },
43
46
  });
44
47
  }
45
- function entityClient(entity, transport) {
48
+ function createCatalogReader(transport) {
49
+ let catalogPromise;
50
+ let catalogHash;
51
+ const observedTransport = {
52
+ async fetch(path, init) {
53
+ const response = await transport.fetch(path, init);
54
+ const hash = response.headers.get("x-ontology-hash");
55
+ if (catalogHash !== undefined && hash !== null && hash !== catalogHash) {
56
+ catalogPromise = undefined;
57
+ catalogHash = undefined;
58
+ }
59
+ return response;
60
+ },
61
+ };
62
+ async function readCatalog() {
63
+ try {
64
+ const catalog = await send(observedTransport, "GET", "", undefined, undefined);
65
+ catalogHash = catalog.hash;
66
+ return catalog;
67
+ }
68
+ catch (error) {
69
+ catalogPromise = undefined;
70
+ throw error;
71
+ }
72
+ }
73
+ function getCatalog() {
74
+ catalogPromise ??= readCatalog();
75
+ return catalogPromise;
76
+ }
77
+ return { transport: observedTransport, getCatalog };
78
+ }
79
+ function entityClient(entity, transport, getCatalog) {
46
80
  const base = `/${entity}`;
47
81
  const actionCalls = new Map();
48
82
  const runAction = (name, input, options = {}) => send(transport, "POST", `${base}/actions/${encodeURIComponent(name)}`, {
49
83
  input,
50
84
  ...(options.validateOnly === undefined
51
85
  ? {}
52
- : { options: { validateOnly: options.validateOnly } }),
86
+ : { options: { validate_only: options.validateOnly } }),
53
87
  }, options.signal);
54
88
  const actions = new Proxy(Object.create(null), {
55
89
  get(_target, property) {
@@ -65,11 +99,15 @@ function entityClient(entity, transport) {
65
99
  },
66
100
  });
67
101
  return {
68
- find: (query = {}, options = {}) => {
69
- const search = findParameters(query).toString();
102
+ find: async (query = {}, options = {}) => {
103
+ const where = await encodeWhere({ where: query.where, entity, getCatalog });
104
+ const search = findParameters({ ...query, where }).toString();
70
105
  return send(transport, "GET", search === "" ? base : `${base}?${search}`, undefined, options.signal);
71
106
  },
72
- query: (query = {}, options = {}) => send(transport, "POST", `${base}/query`, query, options.signal),
107
+ query: async (query = {}, options = {}) => {
108
+ const where = await encodeWhere({ where: query.where, entity, getCatalog });
109
+ return send(transport, "POST", `${base}/query`, queryToWire({ ...query, where }), options.signal);
110
+ },
73
111
  get: (id, options = {}) => send(transport, "GET", `${base}/${recordId(id)}`, undefined, options.signal),
74
112
  history: (id, options = {}) => {
75
113
  const search = new URLSearchParams();
@@ -105,7 +143,7 @@ export function findParameters(query) {
105
143
  search.set("where", JSON.stringify(query.where));
106
144
  }
107
145
  if (query.orderBy !== undefined && query.orderBy.length > 0) {
108
- search.set("orderBy", query.orderBy.map(({ field, direction }) => `${field}:${direction ?? "asc"}`).join(","));
146
+ search.set("order_by", query.orderBy.map(({ field, direction }) => `${field}:${direction ?? "asc"}`).join(","));
109
147
  }
110
148
  if (query.include !== undefined) {
111
149
  const relationships = Object.keys(query.include).filter((name) => query.include?.[name] === true);
@@ -144,5 +182,5 @@ async function send(transport, method, path, body, signal) {
144
182
  retryable: true,
145
183
  });
146
184
  }
147
- return payload;
185
+ return responseFromWire({ method, path, payload });
148
186
  }
@@ -8,8 +8,8 @@
8
8
  * ontology runtime. Keep the two in step.
9
9
  */
10
10
  const WILDCARD = "*";
11
- /** `<entity>:<verb>` with lowercase snake_case entities and lower camelCase verbs, or `*` for either. */
12
- const PERMISSION = /^(\*|[a-z][a-z0-9]*(?:_[a-z0-9]+)*):(\*|[a-z][a-zA-Z0-9]*)$/;
11
+ /** `<entity>:<verb>` with lowercase snake_case entities and lowercase snake_case verbs, or `*` for either. */
12
+ const PERMISSION = /^(\*|[a-z][a-z0-9]*(?:_[a-z0-9]+)*):(\*|[a-z][a-z0-9]*(?:_[a-z0-9]+)*)$/;
13
13
  function parsePermission(value) {
14
14
  if (typeof value !== "string") {
15
15
  return undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@segmentstream-cloud/app",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Sign-in, users, permissions, the ontology client and warehouse data access for SegmentStream workspace apps built with Next.js.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "SegmentStream",