@segmentstream-cloud/ontology 0.15.0 → 0.17.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.
@@ -1,10 +1,12 @@
1
1
  import { BlobValue } from "../blob.js";
2
2
  import { Schema, SchemaTransformation } from "effect";
3
3
  import { wireStruct } from "../wire.js";
4
+ import { AGGREGATE_FILTER_OPERATORS, ENTITY_AGGREGATE_FILTER_OPERATORS, filterValuesIssue, MAXIMUM_FILTER_VALUES, } from "../aggregate-filters.js";
4
5
  import { isValueMatched, OntologyManifestWire } from "../manifest.js";
5
6
  import { entityQuerySchemas, queryLinks } from "../query.js";
6
7
  import { ValidationIssue as RuntimeValidationIssue } from "../runtime/errors.js";
7
8
  import { Edit, HistoryEntry as RuntimeHistoryEntry } from "../runtime/history.js";
9
+ export { AGGREGATE_FILTER_OPERATORS, AGGREGATE_PERIOD_UNITS, AGGREGATE_PERIODS, ENTITY_AGGREGATE_FILTER_OPERATORS, } from "../aggregate-filters.js";
8
10
  /** `lead` → `Lead`, `pilot_request` → `PilotRequest`: component names in the OpenAPI document. */
9
11
  function pascalCase(name) {
10
12
  return name
@@ -312,54 +314,29 @@ export const AGGREGATE_LIMITS = {
312
314
  measures: 30,
313
315
  dimensions: 20,
314
316
  filters: 50,
315
- values: 100,
317
+ values: MAXIMUM_FILTER_VALUES,
316
318
  orderBy: 50,
317
319
  rows: 1000,
318
320
  };
319
- export const AGGREGATE_FILTER_OPERATORS = [
320
- "equals",
321
- "in",
322
- "isNull",
323
- "isNotNull",
324
- "between",
325
- ];
326
- /** The operators of the per-entity shorthand, which bounds dates with `dateRange` instead. */
327
- export const ENTITY_AGGREGATE_FILTER_OPERATORS = ["equals", "in", "isNull", "isNotNull"];
328
- export const AGGREGATE_GRANULARITIES = ["day", "week", "month"];
321
+ /**
322
+ * The granularities a date or timestamp dimension buckets by; `hour` applies to timestamps only.
323
+ */
324
+ export const AGGREGATE_GRANULARITIES = ["hour", "day", "week", "month", "quarter", "year"];
325
+ /** The longest `timezone` a request names. */
326
+ export const MAXIMUM_TIMEZONE_LENGTH = 64;
329
327
  const AggregateValue = Schema.Union([Schema.String, Schema.Number, Schema.Boolean]);
330
- /** Which values each operator takes, with SegmentStream's own messages (`planner.ts`). */
331
- function filterValuesIssue(filter) {
332
- const { operator, value, values } = filter;
333
- switch (operator) {
334
- case "equals":
335
- return value === undefined || values !== undefined
336
- ? "Equality filters require one value."
337
- : undefined;
338
- case "in":
339
- return values === undefined ||
340
- values.length === 0 ||
341
- values.length > AGGREGATE_LIMITS.values ||
342
- value !== undefined
343
- ? "Membership filters require between 1 and 100 values."
344
- : undefined;
345
- case "between":
346
- return value !== undefined || values?.length !== 2
347
- ? "between needs two values in order."
348
- : undefined;
349
- default:
350
- return value !== undefined || values !== undefined
351
- ? "Null filters do not accept values."
352
- : undefined;
353
- }
354
- }
355
- function aggregateFilter(operators) {
328
+ /**
329
+ * A filter of either request. Which values each operator takes is checked with SegmentStream's own
330
+ * messages (`filterValuesIssue`); whether they suit the dimension's type is SegmentStream's to check.
331
+ */
332
+ function aggregateFilter(operators, descriptions) {
356
333
  const filter = closedStruct({
357
334
  dimension: Schema.String.annotate({
358
335
  description: "A property, or a path of to-one relationships ending in one.",
359
336
  }),
360
337
  operator: Schema.Literals(operators),
361
- value: Schema.optionalKey(AggregateValue).annotate({ description: "The value of equals." }),
362
- values: Schema.optionalKey(Schema.Array(AggregateValue).check(Schema.isMaxLength(AGGREGATE_LIMITS.values))).annotate({ description: "The values of in, or the two inclusive bounds of between." }),
338
+ value: Schema.optionalKey(AggregateValue).annotate({ description: descriptions.value }),
339
+ values: Schema.optionalKey(Schema.Array(AggregateValue).check(Schema.isMaxLength(AGGREGATE_LIMITS.values))).annotate({ description: descriptions.values }),
363
340
  }, "Unknown key; use dimension, operator, value and values");
364
341
  return filter.check(Schema.makeFilter((candidate) => filterValuesIssue(candidate) ?? true));
365
342
  }
@@ -374,6 +351,11 @@ const aggregateFields = {
374
351
  granularityOf: Schema.optionalKey(Schema.String).annotate({
375
352
  description: "The requested date or timestamp dimension that granularity buckets.",
376
353
  }),
354
+ timezone: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(MAXIMUM_TIMEZONE_LENGTH))).annotate({
355
+ description: "An IANA timezone, such as Europe/Moscow, or UTC. Buckets of timestamps, date bounds on " +
356
+ "timestamps and inPeriod's today follow it instead of each entity's timezone; measure " +
357
+ "conditions never do.",
358
+ }),
377
359
  orderBy: Schema.optionalKey(Schema.Array(AggregateOrder).check(Schema.isMaxLength(AGGREGATE_LIMITS.orderBy))),
378
360
  limit: Schema.optionalKey(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: AGGREGATE_LIMITS.rows }))).annotate({ description: "At most 1000 rows; 100 by default." }),
379
361
  };
@@ -389,9 +371,15 @@ export const AggregateRequest = closedStruct({
389
371
  description: "<entity>.<property>, or a route from a measure's entity: " +
390
372
  "<entity>.<relationship>[.<relationship>].<property>.",
391
373
  }),
392
- filters: Schema.optionalKey(Schema.Array(aggregateFilter(AGGREGATE_FILTER_OPERATORS)).check(Schema.isMaxLength(AGGREGATE_LIMITS.filters))).annotate({ description: "All must hold." }),
374
+ filters: Schema.optionalKey(Schema.Array(aggregateFilter(AGGREGATE_FILTER_OPERATORS, {
375
+ value: "The value of equals and notEquals; the bound of gt, gte, lt and lte; a non-empty " +
376
+ "string for contains, startsWith and endsWith; the period of inPeriod, such as " +
377
+ "last 7 days.",
378
+ values: "The values of in and notIn, or the two inclusive bounds of between.",
379
+ })).check(Schema.isMaxLength(AGGREGATE_LIMITS.filters))).annotate({ description: "All must hold." }),
393
380
  ...aggregateFields,
394
- }, "Unknown key; use measures, dimensions, filters, granularity, granularityOf, orderBy and limit").annotate({ identifier: "AggregateRequest" });
381
+ }, "Unknown key; use measures, dimensions, filters, granularity, granularityOf, timezone, orderBy " +
382
+ "and limit").annotate({ identifier: "AggregateRequest" });
395
383
  /**
396
384
  * `POST /api/ontology/<entity>/aggregate`: SegmentStream's per-entity request, unchanged. Names are
397
385
  * relative to the entity (`revenue`, `product.brand`), and `dateRange` bounds a partitioned entity.
@@ -409,10 +397,14 @@ export const EntityAggregateRequest = closedStruct({
409
397
  from: Schema.String.annotate({ format: "date" }),
410
398
  to: Schema.String.annotate({ format: "date" }),
411
399
  }, "Unknown key; use from and to")).annotate({ description: "Inclusive days of the entity's partition property." }),
412
- filters: Schema.optionalKey(Schema.Array(aggregateFilter(ENTITY_AGGREGATE_FILTER_OPERATORS)).check(Schema.isMaxLength(AGGREGATE_LIMITS.filters))).annotate({ description: "All must hold." }),
400
+ filters: Schema.optionalKey(Schema.Array(aggregateFilter(ENTITY_AGGREGATE_FILTER_OPERATORS, {
401
+ value: "The value of equals and notEquals; the bound of gt, gte, lt and lte; a non-empty " +
402
+ "string for contains, startsWith and endsWith.",
403
+ values: "The values of in and notIn.",
404
+ })).check(Schema.isMaxLength(AGGREGATE_LIMITS.filters))).annotate({ description: "All must hold." }),
413
405
  ...aggregateFields,
414
406
  }, "Unknown key; use dimensions, measures, dateRange, filters, granularity, granularityOf, " +
415
- "orderBy and limit").annotate({ identifier: "EntityAggregateRequest" });
407
+ "timezone, orderBy and limit").annotate({ identifier: "EntityAggregateRequest" });
416
408
  /** SegmentStream's answer to an aggregate, which the service forwards unchanged. */
417
409
  export const AggregateResult = Schema.Struct({
418
410
  fields: Schema.Array(Schema.Struct({
@@ -429,6 +421,10 @@ export const AggregateResult = Schema.Struct({
429
421
  filters: Schema.optionalKey(Schema.Array(Schema.String)).annotate({
430
422
  description: "The filters the measure applied.",
431
423
  }),
424
+ balanceOver: Schema.optionalKey(Schema.String).annotate({
425
+ description: "The date or timestamp dimension a balance measure was grouped by or filtered to one " +
426
+ "day of.",
427
+ }),
432
428
  })),
433
429
  rows: Schema.Array(Schema.Record(Schema.String, Schema.NullOr(Schema.Union([Schema.String, Schema.Boolean])))),
434
430
  truncated: Schema.Boolean,
@@ -440,6 +436,13 @@ export const AggregateResult = Schema.Struct({
440
436
  description: "For example, a filter that did not apply to some measure's entity.",
441
437
  }),
442
438
  sources: Schema.optionalKey(Schema.Array(Schema.Struct({ entity: Schema.String, asOf: Schema.String }))).annotate({ description: "How fresh the replica of each stored entity read is." }),
439
+ periods: Schema.optionalKey(Schema.Array(Schema.Struct({
440
+ dimension: Schema.String,
441
+ value: Schema.String,
442
+ from: Schema.String.annotate({ format: "date" }),
443
+ to: Schema.String.annotate({ format: "date" }),
444
+ timezone: Schema.String,
445
+ }))).annotate({ description: "The inclusive dates each inPeriod filter used, in request order." }),
443
446
  }),
444
447
  deploymentId: Schema.NullOr(Schema.String),
445
448
  definitionDigest: Schema.String,
@@ -4,6 +4,7 @@ import { HttpApiBuilder } from "effect/unstable/httpapi";
4
4
  import { storageMaintenanceAuthenticationLayer } from "../storage/maintenance-auth.js";
5
5
  import { nameToWire } from "../authoring-names.js";
6
6
  import { sqlServiceRuntime } from "../runtime/service-binding.js";
7
+ import { attachmentsLayer } from "./agent/attachments.js";
7
8
  import { deployedAgentChannel } from "./agent/channel.js";
8
9
  import { agentLayer } from "./agent/server.js";
9
10
  import { makeAggregates } from "./aggregate.js";
@@ -116,7 +117,16 @@ export function createOntologyService(ontology, config) {
116
117
  agentChannel,
117
118
  }), oauthLayer({ verifyToken }));
118
119
  const agent = agentLayer({ verifyToken, verifyRuntime, exchangeSession });
119
- const application = Layer.mergeAll(routes, mcp, agent, DocsRoute, UnmatchedRoutes).pipe(Layer.provide(backend), Layer.provide(HttpServer.layerServices));
120
+ const attachments = attachmentsLayer({
121
+ plan: built.plan,
122
+ hooks,
123
+ connections,
124
+ replicate,
125
+ agentChannel,
126
+ verifyToken,
127
+ verifyRuntime,
128
+ });
129
+ const application = Layer.mergeAll(routes, mcp, agent, attachments, DocsRoute, UnmatchedRoutes).pipe(Layer.provide(backend), Layer.provide(HttpServer.layerServices));
120
130
  const web = HttpRouter.toWebHandler(application, { disableLogger: true });
121
131
  const hash = ontology.manifest.hash;
122
132
  return {
@@ -0,0 +1,117 @@
1
+ import { Effect, Schema } from "effect";
2
+ import type { SqlClient } from "effect/unstable/sql";
3
+ import { ActionRejected, Conflict, NotFound, type PermissionDenied } from "../errors.js";
4
+ import type { Ontology } from "../ontology.js";
5
+ import type { Principal } from "../runtime.js";
6
+ import type { BlobProvider, TransferGrant } from "./provider.js";
7
+ /**
8
+ * The agent's temporary attachments: a file a member sends the app's agent is stored once, as a
9
+ * `_blobs` revision in the ontology's private file storage, and belongs to that member, the chat it
10
+ * arrived in and the agent session that first reads it. Nothing about it is an ontology record. It
11
+ * expires 24 hours after receipt; within that time the member's agent may read it, discard it, or
12
+ * promote it into a record through an action, which adopts the same stored object without copying
13
+ * it. Every operation re-derives the owner, chat and session from the caller, never from input.
14
+ */
15
+ /** The largest file the agent takes: what Telegram's Bot API lets a bot download. */
16
+ export declare const MAX_ATTACHMENT_BYTES: number;
17
+ /** How long an attachment stays readable after it arrives; reads never extend it. */
18
+ export declare const ATTACHMENT_TTL_HOURS = 24;
19
+ /** Live attachments one member may hold at once, so a busy chat cannot fill the storage. */
20
+ export declare const MAX_LIVE_ATTACHMENTS = 50;
21
+ export declare const ATTACHMENT_SOURCES: readonly ["telegram"];
22
+ /** Who asks, as the agent's runtime knows it: the member, their chat and the agent session. */
23
+ export interface AttachmentCaller {
24
+ readonly principal: Principal;
25
+ /** The chat the attachment arrived in, such as `telegram:12345`. */
26
+ readonly conversation: string;
27
+ /**
28
+ * The agent session the request comes from. The first session that reads an attachment owns it;
29
+ * other sessions of the same chat, such as one after a reset, never see it.
30
+ */
31
+ readonly sessionId?: string;
32
+ }
33
+ export interface ReceiveAttachment {
34
+ readonly source: (typeof ATTACHMENT_SOURCES)[number];
35
+ /** Identifies the file at its source, such as the message and attachment index; retries reuse it. */
36
+ readonly sourceRef: string;
37
+ readonly filename: string;
38
+ readonly contentType: string;
39
+ readonly size: number;
40
+ readonly sha256: string;
41
+ }
42
+ export interface AttachmentView {
43
+ readonly id: string;
44
+ readonly filename: string;
45
+ readonly contentType: string;
46
+ readonly size: number;
47
+ readonly sha256: string;
48
+ readonly state: string;
49
+ readonly createdAt: string;
50
+ readonly expiresAt: string;
51
+ readonly promotion: unknown;
52
+ }
53
+ export interface PromotionTarget {
54
+ /** The entity whose action adopts the file, such as `document`. */
55
+ readonly entity: string;
56
+ /** The action, such as `upload` or `create`. */
57
+ readonly action: string;
58
+ /** The action's input without the file; the field that takes the upload is filled in. */
59
+ readonly input: Readonly<Record<string, unknown>>;
60
+ /** The input field that takes the upload, when the action has more than one. */
61
+ readonly field?: string;
62
+ /** The record the action updates, for an action that sets the file on an existing record. */
63
+ readonly recordId?: string;
64
+ }
65
+ /** Runs the promotion's action as the caller, with the upload's options. */
66
+ export type PromotionRunner = (input: {
67
+ readonly entity: string;
68
+ readonly action: string;
69
+ readonly input: Readonly<Record<string, unknown>>;
70
+ readonly options: {
71
+ readonly idempotencyKey: string;
72
+ readonly createIntent?: string;
73
+ };
74
+ }) => Effect.Effect<{
75
+ readonly result: unknown;
76
+ readonly edits: ReadonlyArray<unknown>;
77
+ }, unknown>;
78
+ /** The fields of an action's input that take an upload, with the property each one writes. */
79
+ export declare function uploadFields(input: Schema.Top): ReadonlyArray<{
80
+ readonly field: string;
81
+ readonly entity: string;
82
+ readonly property: string;
83
+ }>;
84
+ /** Persisted phases surround every provider call; no external I/O holds a database transaction. */
85
+ export declare function attachmentRuntime(ontology: Ontology, sql: SqlClient.SqlClient, provider: BlobProvider["Service"]): {
86
+ receive: (input: ReceiveAttachment, caller: AttachmentCaller) => Effect.Effect<{
87
+ attachment: AttachmentView;
88
+ upload?: undefined;
89
+ } | {
90
+ attachment: AttachmentView;
91
+ upload: TransferGrant;
92
+ }, ActionRejected | PermissionDenied | Conflict | NotFound | import("effect/unstable/sql/SqlError").SqlError | import("./provider.js").BlobStorageError, never>;
93
+ complete: (id: string, caller: AttachmentCaller) => Effect.Effect<AttachmentView, ActionRejected | PermissionDenied | Conflict | NotFound | import("effect/unstable/sql/SqlError").SqlError | import("./provider.js").BlobStorageError, never>;
94
+ list: (caller: AttachmentCaller) => Effect.Effect<AttachmentView[], PermissionDenied | import("effect/unstable/sql/SqlError").SqlError, never>;
95
+ read: (id: string, caller: AttachmentCaller) => Effect.Effect<{
96
+ attachment: AttachmentView;
97
+ download: TransferGrant;
98
+ }, PermissionDenied | Conflict | NotFound | import("effect/unstable/sql/SqlError").SqlError | import("./provider.js").BlobStorageError, never>;
99
+ discard: (id: string, caller: AttachmentCaller) => Effect.Effect<AttachmentView, PermissionDenied | Conflict | NotFound | import("effect/unstable/sql/SqlError").SqlError, never>;
100
+ promote: (id: string, target: PromotionTarget, caller: AttachmentCaller, run: PromotionRunner) => Effect.Effect<{
101
+ attachment: AttachmentView;
102
+ deduplicated: boolean;
103
+ result: {} | null;
104
+ }, unknown, never>;
105
+ };
106
+ export type AttachmentRuntime = ReturnType<typeof attachmentRuntime>;
107
+ export type AttachmentFailure = ActionRejected | Conflict | NotFound | PermissionDenied;
108
+ /**
109
+ * Expires attachments past their deadline and removes the bytes of every expired or discarded one
110
+ * that no record adopted. A revision a promotion ticket still points at is left to the upload
111
+ * maintenance, once the ticket is canceled. Runs from the storage maintenance schedule, so files
112
+ * expire while the agent is idle.
113
+ */
114
+ export declare function maintainAttachments(sql: SqlClient.SqlClient, provider: BlobProvider["Service"], limit?: number): Effect.Effect<{
115
+ expired: number;
116
+ removed: number;
117
+ }, import("effect/unstable/sql/SqlError").SqlError | import("./provider.js").BlobStorageError, never>;