@withloops.ai/contracts 0.3.1 → 0.4.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/dist/api.d.ts +153 -0
- package/dist/api.js +86 -0
- package/dist/fields.d.ts +6 -0
- package/dist/fields.js +11 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/manifest.d.ts +516 -3
- package/dist/manifest.js +161 -3
- package/dist/registry.d.ts +151 -1
- package/dist/sync.d.ts +338 -0
- package/dist/sync.js +123 -0
- package/dist/trust.d.ts +1 -1
- package/examples/attio-sync.manifest.json +146 -0
- package/package.json +1 -1
- package/src/api.ts +110 -0
- package/src/fields.ts +11 -0
- package/src/index.ts +2 -0
- package/src/manifest.ts +161 -3
- package/src/sync.ts +229 -0
package/src/api.ts
CHANGED
|
@@ -84,6 +84,16 @@ export type ListQuery = z.infer<typeof ListQuery>;
|
|
|
84
84
|
|
|
85
85
|
// ── Resources (L0: contacts) ────────────────────────────────────────────────────
|
|
86
86
|
|
|
87
|
+
/** Where the record also lives (`ExternalRef` in sync.ts), shown so a developer can find their own id. Added in 0.4.0. */
|
|
88
|
+
export const RecordRef = z.object({
|
|
89
|
+
appId: z.string(),
|
|
90
|
+
object: z.string(),
|
|
91
|
+
externalId: z.string(),
|
|
92
|
+
/** Fields that CRM owns on this record; a PATCH to one answers `conflict`. */
|
|
93
|
+
lockedFields: z.array(z.string()),
|
|
94
|
+
});
|
|
95
|
+
export type RecordRef = z.infer<typeof RecordRef>;
|
|
96
|
+
|
|
87
97
|
/** A Loops contact as the public API shows it. camelCase, ids as strings, dates ISO. */
|
|
88
98
|
export const Contact = z.object({
|
|
89
99
|
id: z.string(),
|
|
@@ -94,6 +104,8 @@ export const Contact = z.object({
|
|
|
94
104
|
jobTitle: z.string().nullable(),
|
|
95
105
|
companyId: z.string().nullable(),
|
|
96
106
|
ownerId: z.string().nullable(),
|
|
107
|
+
/** Added in 0.4.0. Where this contact also lives (a connected CRM). */
|
|
108
|
+
refs: z.array(RecordRef).optional(),
|
|
97
109
|
createdAt: Timestamp,
|
|
98
110
|
updatedAt: Timestamp,
|
|
99
111
|
});
|
|
@@ -122,3 +134,101 @@ export const ContactUpdate = z.object({
|
|
|
122
134
|
ownerId: z.string().nullable().optional(),
|
|
123
135
|
});
|
|
124
136
|
export type ContactUpdate = z.infer<typeof ContactUpdate>;
|
|
137
|
+
|
|
138
|
+
// ── Resources added in 0.4.0 (L2): companies, deals, record links ───────────────
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
export const Company = z.object({
|
|
142
|
+
id: z.string(),
|
|
143
|
+
name: z.string().nullable(),
|
|
144
|
+
website: z.string().nullable(),
|
|
145
|
+
domain: z.string().nullable(),
|
|
146
|
+
phone: z.string().nullable(),
|
|
147
|
+
industry: z.string().nullable(),
|
|
148
|
+
ownerId: z.string().nullable(),
|
|
149
|
+
refs: z.array(RecordRef).optional(),
|
|
150
|
+
createdAt: Timestamp,
|
|
151
|
+
updatedAt: Timestamp,
|
|
152
|
+
});
|
|
153
|
+
export type Company = z.infer<typeof Company>;
|
|
154
|
+
|
|
155
|
+
export const CompanyCreate = z.object({
|
|
156
|
+
name: z.string().min(1).max(200),
|
|
157
|
+
website: z.string().max(500).optional(),
|
|
158
|
+
phone: z.string().max(40).optional(),
|
|
159
|
+
industry: z.string().max(120).optional(),
|
|
160
|
+
ownerId: z.string().optional(),
|
|
161
|
+
});
|
|
162
|
+
export type CompanyCreate = z.infer<typeof CompanyCreate>;
|
|
163
|
+
|
|
164
|
+
export const CompanyUpdate = z.object({
|
|
165
|
+
name: z.string().min(1).max(200).optional(),
|
|
166
|
+
website: z.string().max(500).nullable().optional(),
|
|
167
|
+
phone: z.string().max(40).nullable().optional(),
|
|
168
|
+
industry: z.string().max(120).nullable().optional(),
|
|
169
|
+
ownerId: z.string().nullable().optional(),
|
|
170
|
+
});
|
|
171
|
+
export type CompanyUpdate = z.infer<typeof CompanyUpdate>;
|
|
172
|
+
|
|
173
|
+
export const Deal = z.object({
|
|
174
|
+
id: z.string(),
|
|
175
|
+
name: z.string().nullable(),
|
|
176
|
+
/** Amount in the currency's units (12500.5). */
|
|
177
|
+
value: z.number().nullable(),
|
|
178
|
+
currency: z.string().nullable(),
|
|
179
|
+
stage: z.string().nullable(),
|
|
180
|
+
closeDate: z.string().nullable(),
|
|
181
|
+
companyId: z.string().nullable(),
|
|
182
|
+
ownerId: z.string().nullable(),
|
|
183
|
+
refs: z.array(RecordRef).optional(),
|
|
184
|
+
createdAt: Timestamp,
|
|
185
|
+
updatedAt: Timestamp,
|
|
186
|
+
});
|
|
187
|
+
export type Deal = z.infer<typeof Deal>;
|
|
188
|
+
|
|
189
|
+
export const DealCreate = z.object({
|
|
190
|
+
name: z.string().min(1).max(200),
|
|
191
|
+
value: z.number().min(0).optional(),
|
|
192
|
+
currency: z.string().length(3).optional(),
|
|
193
|
+
stage: z.string().max(120).optional(),
|
|
194
|
+
closeDate: z.string().optional(),
|
|
195
|
+
companyId: z.string().optional(),
|
|
196
|
+
ownerId: z.string().optional(),
|
|
197
|
+
});
|
|
198
|
+
export type DealCreate = z.infer<typeof DealCreate>;
|
|
199
|
+
|
|
200
|
+
export const DealUpdate = z.object({
|
|
201
|
+
name: z.string().min(1).max(200).optional(),
|
|
202
|
+
value: z.number().min(0).nullable().optional(),
|
|
203
|
+
currency: z.string().length(3).nullable().optional(),
|
|
204
|
+
stage: z.string().max(120).nullable().optional(),
|
|
205
|
+
closeDate: z.string().nullable().optional(),
|
|
206
|
+
companyId: z.string().nullable().optional(),
|
|
207
|
+
ownerId: z.string().nullable().optional(),
|
|
208
|
+
});
|
|
209
|
+
export type DealUpdate = z.infer<typeof DealUpdate>;
|
|
210
|
+
|
|
211
|
+
/** Loops record kinds as the API names them in links. */
|
|
212
|
+
export const LinkKind = z.enum(['contact', 'company', 'lead', 'deal']);
|
|
213
|
+
export type LinkKind = z.infer<typeof LinkKind>;
|
|
214
|
+
|
|
215
|
+
/** A link between two records (`recordLinks` in Vista): a deal holds many people, each with a role. */
|
|
216
|
+
export const RecordLink = z.object({
|
|
217
|
+
id: z.string(),
|
|
218
|
+
from: z.object({ kind: LinkKind, id: z.string() }),
|
|
219
|
+
to: z.object({ kind: LinkKind, id: z.string() }),
|
|
220
|
+
role: z.string().nullable(),
|
|
221
|
+
createdAt: Timestamp,
|
|
222
|
+
});
|
|
223
|
+
export type RecordLink = z.infer<typeof RecordLink>;
|
|
224
|
+
|
|
225
|
+
export const RecordLinkCreate = z.object({
|
|
226
|
+
from: z.object({ kind: LinkKind, id: z.string().min(1) }),
|
|
227
|
+
to: z.object({ kind: LinkKind, id: z.string().min(1) }),
|
|
228
|
+
role: z.string().max(80).optional(),
|
|
229
|
+
});
|
|
230
|
+
export type RecordLinkCreate = z.infer<typeof RecordLinkCreate>;
|
|
231
|
+
|
|
232
|
+
/** `GET /v1/links?kind=deal&id=…`: the links of one record, either end. */
|
|
233
|
+
export const RecordLinkQuery = z.object({ kind: LinkKind, id: z.string().min(1) });
|
|
234
|
+
export type RecordLinkQuery = z.infer<typeof RecordLinkQuery>;
|
package/src/fields.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Added in 0.4.0 (L2). The Loops fields a connector maps into, per record kind. `custom.<key>` reaches a custom field.
|
|
3
|
+
* Link ids (companyId on a contact, people on a deal) are NOT fields: they come from `sync.links` and become `recordLinks`.
|
|
4
|
+
* Only ever ADD names here.
|
|
5
|
+
*/
|
|
6
|
+
export const LOOPS_FIELD_NAMES: Record<'CONTACT' | 'COMPANY' | 'LEAD' | 'DEAL', readonly string[]> = {
|
|
7
|
+
CONTACT: ['firstName', 'lastName', 'email', 'phone', 'mobile', 'jobTitle', 'linkedinUrl', 'city', 'country', 'ownerEmail'],
|
|
8
|
+
COMPANY: ['name', 'website', 'domain', 'phone', 'industry', 'city', 'country', 'linkedinUrl', 'ownerEmail'],
|
|
9
|
+
LEAD: ['firstName', 'lastName', 'email', 'phone', 'jobTitle', 'companyName', 'website', 'status', 'source', 'ownerEmail'],
|
|
10
|
+
DEAL: ['name', 'value', 'currency', 'stage', 'closeDate', 'probability', 'ownerEmail'],
|
|
11
|
+
};
|
package/src/index.ts
CHANGED
package/src/manifest.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { z } from 'zod';
|
|
|
2
2
|
import { AppId, HttpsUrl, SemVer } from './ids.ts';
|
|
3
3
|
import { EventTypeSchema, scopeForEvent } from './events.ts';
|
|
4
4
|
import { ScopeSchema, expandScopes, type Scope } from './scopes.ts';
|
|
5
|
+
import { LOOPS_FIELD_NAMES } from './fields.ts';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* App manifest v1 (`loops.manifest.json`). Everything is a Loops App: one manifest, two optional halves —
|
|
@@ -29,6 +30,52 @@ const RECORD_SCOPE: Record<string, { read: Scope; write: Scope }> = {
|
|
|
29
30
|
|
|
30
31
|
const PathTemplate = z.string().regex(/^\/[A-Za-z0-9_\-./{}:]*$/, 'A path starting with /, e.g. /crm/v3/objects/contacts');
|
|
31
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Added in 0.4.0 (L2). A JSON body template. A string that is exactly `{{name}}` takes the value as it is (type kept);
|
|
35
|
+
* `{{name}}` inside longer text is written as text. A key whose value comes out null or missing is left out, so is a null
|
|
36
|
+
* array item and an object left empty. Names: see `TEMPLATE_VARIABLES` in sync.ts. Pure: no code, no loops, no conditions.
|
|
37
|
+
*/
|
|
38
|
+
export const BodyTemplate = z.json();
|
|
39
|
+
|
|
40
|
+
/** Added in 0.4.0. A dotted path into a JSON answer: `data`, `values.name[0].first_name`, `values.associated_people[*].target_record_id`. */
|
|
41
|
+
export const JsonPath = z.string().regex(/^[A-Za-z0-9_$-]+(\[(\d+|\*)\])?(\.[A-Za-z0-9_$-]+(\[(\d+|\*)\])?)*$/, 'A dotted path like data.items or values.name[0].first_name');
|
|
42
|
+
|
|
43
|
+
/** Added in 0.4.0. Small pure value transforms, applied in order when reading their value. */
|
|
44
|
+
export const ValueTransform = z.enum(['trim', 'lower', 'upper', 'string', 'number', 'boolean', 'isoDate', 'first', 'domainFromUrl']);
|
|
45
|
+
|
|
46
|
+
/** Added in 0.4.0. How a list call pages. `cursor`: their answer holds the next cursor; `offset`/`page`: count up until a short page. */
|
|
47
|
+
export const Paging = z.discriminatedUnion('style', [
|
|
48
|
+
z.object({ style: z.literal('cursor'), param: z.string().min(1), nextPath: JsonPath, in: z.enum(['query', 'body']).default('query') }),
|
|
49
|
+
z.object({
|
|
50
|
+
style: z.literal('offset'),
|
|
51
|
+
param: z.string().min(1),
|
|
52
|
+
sizeParam: z.string().min(1),
|
|
53
|
+
size: z.number().int().min(1).max(1000).default(100),
|
|
54
|
+
in: z.enum(['query', 'body']).default('query'),
|
|
55
|
+
}),
|
|
56
|
+
z.object({
|
|
57
|
+
style: z.literal('page'),
|
|
58
|
+
param: z.string().min(1),
|
|
59
|
+
sizeParam: z.string().min(1),
|
|
60
|
+
size: z.number().int().min(1).max(1000).default(100),
|
|
61
|
+
startAt: z.number().int().min(0).max(1).default(1),
|
|
62
|
+
in: z.enum(['query', 'body']).default('query'),
|
|
63
|
+
}),
|
|
64
|
+
]);
|
|
65
|
+
export type Paging = z.infer<typeof Paging>;
|
|
66
|
+
|
|
67
|
+
/** Added in 0.4.0. One HTTP call against their API. `{id}` in the path is their record id. */
|
|
68
|
+
export const RecordCall = z.object({
|
|
69
|
+
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).default('GET'),
|
|
70
|
+
path: PathTemplate,
|
|
71
|
+
body: BodyTemplate.optional(),
|
|
72
|
+
/** Where the record sits in the answer (`data`); the whole answer when left out. */
|
|
73
|
+
itemPath: JsonPath.optional(),
|
|
74
|
+
/** Where their id sits in the answer of a create (`data.id.record_id`); defaults to the object's idPath under itemPath. */
|
|
75
|
+
idPath: JsonPath.optional(),
|
|
76
|
+
});
|
|
77
|
+
export type RecordCall = z.infer<typeof RecordCall>;
|
|
78
|
+
|
|
32
79
|
// ── sync: the connector half ────────────────────────────────────────────────────
|
|
33
80
|
|
|
34
81
|
export const SyncAuth = z.discriminatedUnion('type', [
|
|
@@ -45,6 +92,9 @@ export const SyncAuth = z.discriminatedUnion('type', [
|
|
|
45
92
|
type: z.literal('apiKey'),
|
|
46
93
|
header: z.string().min(1).default('Authorization'),
|
|
47
94
|
prefix: z.string().optional(),
|
|
95
|
+
/** Added in 0.4.0. What the admin is asked for at install ("Attio access token"), and where to find it. */
|
|
96
|
+
label: z.string().min(1).max(80).optional(),
|
|
97
|
+
helpUrl: HttpsUrl.optional(),
|
|
48
98
|
}),
|
|
49
99
|
]);
|
|
50
100
|
export type SyncAuth = z.infer<typeof SyncAuth>;
|
|
@@ -53,9 +103,13 @@ export type SyncAuth = z.infer<typeof SyncAuth>;
|
|
|
53
103
|
export const FieldOwner = z.enum(['theirs', 'ours']);
|
|
54
104
|
|
|
55
105
|
export const SyncField = z.object({
|
|
106
|
+
/** Where the value sits in their record: a field name or a JsonPath (`values.email_addresses[0].email_address`). */
|
|
56
107
|
theirs: z.string().min(1),
|
|
108
|
+
/** The Loops field (`LOOPS_FIELDS` in sync.ts) or `custom.<key>` for a custom field. */
|
|
57
109
|
ours: z.string().min(1),
|
|
58
110
|
owner: FieldOwner,
|
|
111
|
+
/** Added in 0.4.0. Applied in order to their value when reading it. */
|
|
112
|
+
transform: z.array(ValueTransform).optional(),
|
|
59
113
|
});
|
|
60
114
|
|
|
61
115
|
export const SyncObject = z.object({
|
|
@@ -67,16 +121,34 @@ export const SyncObject = z.object({
|
|
|
67
121
|
cursorParam: z.string().optional(),
|
|
68
122
|
nextCursorPath: z.string().optional(),
|
|
69
123
|
updatedSinceParam: z.string().optional(),
|
|
124
|
+
/** Added in 0.4.0. GET by default; POST for APIs that query with a body. */
|
|
125
|
+
method: z.enum(['GET', 'POST']).optional(),
|
|
126
|
+
/** Added in 0.4.0. Body for a POST list (`{{limit}}`, `{{offset}}`, `{{cursor}}`, `{{updatedSince}}` fill in). */
|
|
127
|
+
body: BodyTemplate.optional(),
|
|
128
|
+
/** Added in 0.4.0. Replaces cursorParam/nextCursorPath when set. */
|
|
129
|
+
paging: Paging.optional(),
|
|
70
130
|
}),
|
|
71
131
|
fields: z.array(SyncField).min(1),
|
|
72
132
|
/** Dedupe keys, in order: e.g. ["email"] for contacts, ["domain"] for companies. External id always matches first. */
|
|
73
133
|
match: z.array(z.string().min(1)).default([]),
|
|
134
|
+
/** Added in 0.4.0. Where their id sits in one item (default `id`). */
|
|
135
|
+
idPath: JsonPath.optional(),
|
|
136
|
+
/** Added in 0.4.0. Where their last-changed time sits; without it, polling compares each record with what it saw last. */
|
|
137
|
+
updatedAtPath: JsonPath.optional(),
|
|
138
|
+
/** Added in 0.4.0. Read one record (`/v1/people/{id}`); needed when their webhook sends only ids. */
|
|
139
|
+
get: RecordCall.optional(),
|
|
140
|
+
/** Added in 0.4.0. Create a record Loops made (all mapped fields are filled). Without it, Loops never creates records there. */
|
|
141
|
+
create: RecordCall.optional(),
|
|
142
|
+
/** Added in 0.4.0. Update a record: only fields owned by Loops (`owner: ours`) are filled; their fields are left out. */
|
|
143
|
+
update: RecordCall.optional(),
|
|
74
144
|
});
|
|
75
145
|
|
|
76
146
|
export const SyncLink = z.object({
|
|
77
147
|
from: RecordKind,
|
|
78
148
|
to: RecordKind,
|
|
149
|
+
/** Where the other record's id sits on `from`'s item: a field or a JsonPath; `[*]` for many (`values.associated_people[*].target_record_id`). */
|
|
79
150
|
theirs: z.string().min(1),
|
|
151
|
+
/** The role on the Loops record link (`recordLinks`), e.g. `decision maker`, `employee`. */
|
|
80
152
|
role: z.string().optional(),
|
|
81
153
|
});
|
|
82
154
|
|
|
@@ -84,10 +156,49 @@ export const ChangeFeed = z
|
|
|
84
156
|
.object({
|
|
85
157
|
webhook: z
|
|
86
158
|
.object({
|
|
87
|
-
signature: z.object({
|
|
159
|
+
signature: z.object({
|
|
160
|
+
scheme: z.enum(['hmac-sha256', 'hmac-sha1']),
|
|
161
|
+
header: z.string().min(1),
|
|
162
|
+
/** Added in 0.4.0. How the signature is written (default hex). */
|
|
163
|
+
encoding: z.enum(['hex', 'base64']).optional(),
|
|
164
|
+
/** Added in 0.4.0. Text before the signature in the header, e.g. `sha256=`. */
|
|
165
|
+
prefix: z.string().max(40).optional(),
|
|
166
|
+
}),
|
|
167
|
+
/** Added in 0.4.0. Where the events sit in one delivery (`events`); the body is one event when left out. */
|
|
168
|
+
eventsPath: JsonPath.optional(),
|
|
169
|
+
/** Added in 0.4.0. Where an event says what happened (`event_type`). */
|
|
170
|
+
typePath: JsonPath.optional(),
|
|
171
|
+
/** Added in 0.4.0. Where an event names the record (`id.record_id`). */
|
|
172
|
+
idPath: JsonPath.optional(),
|
|
173
|
+
/** Added in 0.4.0. Where an event names the object; when it matches no object's `theirs`, the engine asks each object's `get`. */
|
|
174
|
+
objectPath: JsonPath.optional(),
|
|
175
|
+
/** Added in 0.4.0. Their event names for each change. A created or updated record is read again with the object's `get`. */
|
|
176
|
+
types: z
|
|
177
|
+
.object({
|
|
178
|
+
created: z.array(z.string().min(1)).default([]),
|
|
179
|
+
updated: z.array(z.string().min(1)).default([]),
|
|
180
|
+
deleted: z.array(z.string().min(1)).default([]),
|
|
181
|
+
})
|
|
182
|
+
.optional(),
|
|
183
|
+
/** Added in 0.4.0. Register the webhook through their API at install (`{{hookUrl}}` in the body); the secret is kept in the vault. */
|
|
184
|
+
register: z
|
|
185
|
+
.object({
|
|
186
|
+
call: RecordCall,
|
|
187
|
+
/** Where the signing secret sits in the answer (`data.secret`). */
|
|
188
|
+
secretPath: JsonPath,
|
|
189
|
+
/** Where the webhook's id sits in the answer, to remove it at uninstall. */
|
|
190
|
+
idPath: JsonPath.optional(),
|
|
191
|
+
/** Remove it at uninstall; `{id}` is the webhook id. */
|
|
192
|
+
unregister: RecordCall.optional(),
|
|
193
|
+
})
|
|
194
|
+
.optional(),
|
|
88
195
|
})
|
|
89
196
|
.optional(),
|
|
90
|
-
polling: z.object({
|
|
197
|
+
polling: z.object({
|
|
198
|
+
everyMinutes: z.number().int().min(5).max(1440),
|
|
199
|
+
/** Added in 0.4.0. `updatedSince` asks only for changed records (needs list.updatedSinceParam); `full` reads every record and keeps the changed ones. Default: updatedSince when the object has the param, else full. */
|
|
200
|
+
mode: z.enum(['updatedSince', 'full']).optional(),
|
|
201
|
+
}).optional(),
|
|
91
202
|
})
|
|
92
203
|
.refine((f) => f.webhook || f.polling, 'Declare a webhook, polling, or both');
|
|
93
204
|
|
|
@@ -95,7 +206,15 @@ export const ChangeFeed = z
|
|
|
95
206
|
export const WriteBackKind = z.enum(['call', 'transcript', 'meeting', 'note', 'action', 'campaignReply']);
|
|
96
207
|
export const WriteBack = z.partialRecord(
|
|
97
208
|
WriteBackKind,
|
|
98
|
-
z.object({
|
|
209
|
+
z.object({
|
|
210
|
+
method: z.enum(['POST', 'PUT', 'PATCH']),
|
|
211
|
+
/** `{id}` = their id of the record the item belongs to, `{object}` = their object name. */
|
|
212
|
+
path: PathTemplate,
|
|
213
|
+
/** Added in 0.4.0. Body template; `{{item.*}}` and `{{record.*}}` fill in (sync.ts TEMPLATE_VARIABLES). */
|
|
214
|
+
body: BodyTemplate.optional(),
|
|
215
|
+
/** Added in 0.4.0. Where their id of the written item sits in the answer, so Loops can find it again. */
|
|
216
|
+
idPath: JsonPath.optional(),
|
|
217
|
+
}),
|
|
99
218
|
);
|
|
100
219
|
|
|
101
220
|
export const SyncLimits = z.object({
|
|
@@ -112,6 +231,8 @@ export const SyncManifest = z.object({
|
|
|
112
231
|
changeFeed: ChangeFeed,
|
|
113
232
|
writeBack: WriteBack.default({}),
|
|
114
233
|
limits: SyncLimits,
|
|
234
|
+
/** Added in 0.4.0. A cheap call that proves the credentials work (`GET /v2/self`). `loops test` and the health check use it. */
|
|
235
|
+
test: RecordCall.optional(),
|
|
115
236
|
});
|
|
116
237
|
export type SyncManifest = z.infer<typeof SyncManifest>;
|
|
117
238
|
|
|
@@ -212,6 +333,7 @@ export const ManifestV1 = z
|
|
|
212
333
|
if (new Set(m.scopes).size !== m.scopes.length) ctx.addIssue({ code: 'custom', path: ['scopes'], message: 'Scopes are listed twice' });
|
|
213
334
|
|
|
214
335
|
m.sync?.objects.forEach((o, i) => need(RECORD_SCOPE[o.ours].write, ['sync', 'objects', i, 'ours'], `Syncing ${o.ours}`));
|
|
336
|
+
if (m.sync) checkSync(m.sync, ctx);
|
|
215
337
|
|
|
216
338
|
if (m.ui) {
|
|
217
339
|
m.ui.uses.forEach((f, i) => need(RECORD_SCOPE[f.split('.')[0]].read, ['ui', 'uses', i], `Reading ${f}`));
|
|
@@ -241,6 +363,42 @@ export const ManifestV1 = z
|
|
|
241
363
|
export type ManifestV1 = z.infer<typeof ManifestV1>;
|
|
242
364
|
export type ManifestV1Input = z.input<typeof ManifestV1>;
|
|
243
365
|
|
|
366
|
+
/** Connector checks added in 0.4.0: Loops field names, `{id}` where a record is named, webhook pieces that need each other. */
|
|
367
|
+
function checkSync(sync: SyncManifest, ctx: z.RefinementCtx): void {
|
|
368
|
+
const issue = (path: (string | number)[], message: string) => ctx.addIssue({ code: 'custom', path: ['sync', ...path], message });
|
|
369
|
+
const seen = new Set<string>();
|
|
370
|
+
sync.objects.forEach((o, i) => {
|
|
371
|
+
if (seen.has(o.theirs)) issue(['objects', i, 'theirs'], `${o.theirs} is listed twice`);
|
|
372
|
+
seen.add(o.theirs);
|
|
373
|
+
const known = LOOPS_FIELD_NAMES[o.ours];
|
|
374
|
+
o.fields.forEach((f, j) => {
|
|
375
|
+
if (!f.ours.startsWith('custom.') && !known.includes(f.ours))
|
|
376
|
+
issue(['objects', i, 'fields', j, 'ours'], `${f.ours} is not a Loops ${o.ours} field (use one of ${known.join(', ')}, or custom.<key>)`);
|
|
377
|
+
});
|
|
378
|
+
o.match.forEach((k, j) => {
|
|
379
|
+
// A match key names a Loops field (preferred) or, as in 0.3.x manifests, their field; either must be mapped.
|
|
380
|
+
if (!o.fields.some((f) => f.ours === k || f.theirs === k)) issue(['objects', i, 'match', j], `Matching on ${k} needs a field mapped to ${k}`);
|
|
381
|
+
});
|
|
382
|
+
for (const call of ['get', 'update'] as const) {
|
|
383
|
+
if (o[call] && !o[call]!.path.includes('{id}')) issue(['objects', i, call, 'path'], `The ${call} path needs {id}`);
|
|
384
|
+
}
|
|
385
|
+
if (o.list.paging && (o.list.paging.in === 'body') !== (o.list.method === 'POST'))
|
|
386
|
+
issue(['objects', i, 'list', 'paging', 'in'], 'Paging in the body needs list.method POST, and a POST list pages in the body');
|
|
387
|
+
});
|
|
388
|
+
sync.links.forEach((l, i) => {
|
|
389
|
+
for (const end of ['from', 'to'] as const)
|
|
390
|
+
if (!sync.objects.some((o) => o.ours === l[end])) issue(['links', i, end], `${l[end]} is linked but no object syncs it`);
|
|
391
|
+
});
|
|
392
|
+
const hook = sync.changeFeed.webhook;
|
|
393
|
+
if (hook?.types && !hook.idPath) issue(['changeFeed', 'webhook', 'idPath'], 'Webhook event types need idPath');
|
|
394
|
+
if (hook?.types && (hook.types.created.length || hook.types.updated.length))
|
|
395
|
+
sync.objects.forEach((o, i) => { if (!o.get) issue(['objects', i, 'get'], 'Webhooks that send ids need a get call to read the record'); });
|
|
396
|
+
if (sync.changeFeed.polling?.mode === 'updatedSince')
|
|
397
|
+
sync.objects.forEach((o, i) => {
|
|
398
|
+
if (!o.list.updatedSinceParam) issue(['objects', i, 'list', 'updatedSinceParam'], 'Polling by updatedSince needs list.updatedSinceParam');
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
244
402
|
/** The manifest file name in an app folder. */
|
|
245
403
|
export const MANIFEST_FILE = 'loops.manifest.json';
|
|
246
404
|
|
package/src/sync.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { AppId, InstallId, Timestamp, UserId, WorkspaceId } from './ids.ts';
|
|
3
|
+
import { RecordKind, WriteBackKind, type SyncManifest } from './manifest.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Added in 0.4.0 (L2 Sync). The engine core's vocabulary: connector kinds, the CRM of record, external refs, health,
|
|
7
|
+
* import progress, mapping preview, write-back items, the driver interface both connector kinds implement, and the RPC the
|
|
8
|
+
* engine adds. Skill devops-loops-developer-platform §2 #1 #4 #5, §3. Only ever ADD.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// ── Connector kinds and the CRM of record ───────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
/** `native` = first-party code we build (HubSpot, Salesforce…); `manifest` = a developer app's `sync`, run by the manifest driver. */
|
|
14
|
+
export const ConnectorKind = z.enum(['native', 'manifest']);
|
|
15
|
+
export type ConnectorKind = z.infer<typeof ConnectorKind>;
|
|
16
|
+
|
|
17
|
+
/** `TenantApps.crmConnector` in Vista. Null there = the native Loops CRM is the CRM of record. One per workspace. */
|
|
18
|
+
export const CrmConnector = z.object({
|
|
19
|
+
appId: AppId,
|
|
20
|
+
installId: InstallId,
|
|
21
|
+
kind: ConnectorKind,
|
|
22
|
+
setBy: UserId,
|
|
23
|
+
setAt: Timestamp,
|
|
24
|
+
});
|
|
25
|
+
export type CrmConnector = z.infer<typeof CrmConnector>;
|
|
26
|
+
|
|
27
|
+
/** A Loops record's identity at the other side (the link table in Vista). */
|
|
28
|
+
export const ExternalRef = z.object({
|
|
29
|
+
appId: AppId,
|
|
30
|
+
installId: InstallId,
|
|
31
|
+
/** Their object name (`people`). */
|
|
32
|
+
object: z.string().min(1),
|
|
33
|
+
externalId: z.string().min(1).max(200),
|
|
34
|
+
/** Loops fields their CRM owns on this record: locked in Loops ("Edited in Attio"). */
|
|
35
|
+
lockedFields: z.array(z.string()).default([]),
|
|
36
|
+
linkedAt: Timestamp,
|
|
37
|
+
});
|
|
38
|
+
export type ExternalRef = z.infer<typeof ExternalRef>;
|
|
39
|
+
|
|
40
|
+
// ── Health, import, preview ─────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* `connecting` (no credentials yet) · `needs_auth` (token refused; an admin reconnects) · `importing` (first import running)
|
|
44
|
+
* · `healthy` · `degraded` (calls failing or rate limited, retrying) · `paused` (auto-pause or an admin paused it).
|
|
45
|
+
*/
|
|
46
|
+
export const SyncHealth = z.enum(['connecting', 'needs_auth', 'importing', 'healthy', 'degraded', 'paused']);
|
|
47
|
+
export type SyncHealth = z.infer<typeof SyncHealth>;
|
|
48
|
+
|
|
49
|
+
export const ObjectProgress = z.object({
|
|
50
|
+
object: z.string(),
|
|
51
|
+
ours: RecordKind,
|
|
52
|
+
enabled: z.boolean(),
|
|
53
|
+
/** Records read from their side in the first import. */
|
|
54
|
+
read: z.number().int().min(0),
|
|
55
|
+
created: z.number().int().min(0),
|
|
56
|
+
matched: z.number().int().min(0),
|
|
57
|
+
failed: z.number().int().min(0),
|
|
58
|
+
/** Their total when their API says it; null otherwise. */
|
|
59
|
+
total: z.number().int().min(0).nullable(),
|
|
60
|
+
lastChangeAt: Timestamp.nullable(),
|
|
61
|
+
});
|
|
62
|
+
export type ObjectProgress = z.infer<typeof ObjectProgress>;
|
|
63
|
+
|
|
64
|
+
export const ImportState = z.enum(['not_started', 'running', 'done', 'failed']);
|
|
65
|
+
|
|
66
|
+
export const SyncStatus = z.object({
|
|
67
|
+
installId: InstallId,
|
|
68
|
+
appId: AppId,
|
|
69
|
+
kind: ConnectorKind,
|
|
70
|
+
health: SyncHealth,
|
|
71
|
+
import: z.object({
|
|
72
|
+
state: ImportState,
|
|
73
|
+
runId: z.string().nullable(),
|
|
74
|
+
startedAt: Timestamp.nullable(),
|
|
75
|
+
finishedAt: Timestamp.nullable(),
|
|
76
|
+
}),
|
|
77
|
+
objects: z.array(ObjectProgress),
|
|
78
|
+
changes: z.object({
|
|
79
|
+
inLastHour: z.number().int().min(0),
|
|
80
|
+
outLastHour: z.number().int().min(0),
|
|
81
|
+
echoesDropped: z.number().int().min(0),
|
|
82
|
+
deadLettered: z.number().int().min(0),
|
|
83
|
+
}),
|
|
84
|
+
/** Plain words, the last problem only. Never a secret, never a stack trace. */
|
|
85
|
+
lastError: z.object({ at: Timestamp, message: z.string() }).nullable(),
|
|
86
|
+
nextPollAt: Timestamp.nullable(),
|
|
87
|
+
});
|
|
88
|
+
export type SyncStatus = z.infer<typeof SyncStatus>;
|
|
89
|
+
|
|
90
|
+
/** A few of their records shown beside what Loops would make of them, before the first import. */
|
|
91
|
+
export const MappingPreview = z.object({
|
|
92
|
+
object: z.string(),
|
|
93
|
+
ours: RecordKind,
|
|
94
|
+
fields: z.array(z.object({ theirs: z.string(), ours: z.string(), owner: z.enum(['theirs', 'ours']) })),
|
|
95
|
+
rows: z.array(
|
|
96
|
+
z.object({
|
|
97
|
+
externalId: z.string(),
|
|
98
|
+
values: z.record(z.string(), z.unknown()),
|
|
99
|
+
links: z.array(z.object({ to: RecordKind, role: z.string().nullable(), externalIds: z.array(z.string()) })),
|
|
100
|
+
}),
|
|
101
|
+
),
|
|
102
|
+
});
|
|
103
|
+
export type MappingPreview = z.infer<typeof MappingPreview>;
|
|
104
|
+
|
|
105
|
+
/** What the admin picked in the bring-your-own-CRM setup. */
|
|
106
|
+
export const SyncSettings = z.object({
|
|
107
|
+
/** Their object names to sync; every object in the manifest when empty. */
|
|
108
|
+
objects: z.array(z.string()).default([]),
|
|
109
|
+
/** Make this install the workspace's CRM of record (`TenantApps.crmConnector`). */
|
|
110
|
+
crmOfRecord: z.boolean().default(false),
|
|
111
|
+
});
|
|
112
|
+
export type SyncSettings = z.infer<typeof SyncSettings>;
|
|
113
|
+
|
|
114
|
+
// ── Write-back ──────────────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
/** One thing Loops owns, written to their timeline. `{{item.*}}` in a write-back body template. */
|
|
117
|
+
export const WriteBackItem = z.object({
|
|
118
|
+
kind: WriteBackKind,
|
|
119
|
+
/** Loops id of the thing (call, note…), for the echo guard and to find it again. */
|
|
120
|
+
loopsId: z.string(),
|
|
121
|
+
title: z.string().max(300),
|
|
122
|
+
/** Plain text. */
|
|
123
|
+
text: z.string().max(100_000),
|
|
124
|
+
markdown: z.string().max(100_000).nullable(),
|
|
125
|
+
occurredAt: Timestamp,
|
|
126
|
+
durationSeconds: z.number().int().min(0).nullable(),
|
|
127
|
+
direction: z.enum(['inbound', 'outbound']).nullable(),
|
|
128
|
+
/** Link back to the item in Loops. */
|
|
129
|
+
url: z.string().nullable(),
|
|
130
|
+
byEmail: z.string().nullable(),
|
|
131
|
+
});
|
|
132
|
+
export type WriteBackItem = z.infer<typeof WriteBackItem>;
|
|
133
|
+
|
|
134
|
+
// ── Template variables (manifest BodyTemplate) ──────────────────────────────────
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Names a body template may use. In paths only `{id}` and `{object}`.
|
|
138
|
+
* - list body: `{{limit}}`, `{{offset}}`, `{{page}}`, `{{cursor}}`, `{{updatedSince}}`
|
|
139
|
+
* - create / update body: `{{ours.<field>}}` (a Loops field from the object's map), `{{record.id}}`
|
|
140
|
+
* - write-back body: `{{item.<field>}}` (WriteBackItem), `{{record.id}}`, `{{record.object}}`
|
|
141
|
+
* - webhook register body: `{{hookUrl}}`, `{{object}}`
|
|
142
|
+
*/
|
|
143
|
+
export const TEMPLATE_VARIABLES = {
|
|
144
|
+
list: ['limit', 'offset', 'page', 'cursor', 'updatedSince'],
|
|
145
|
+
record: ['ours.*', 'record.id'],
|
|
146
|
+
writeBack: ['item.*', 'record.id', 'record.object'],
|
|
147
|
+
register: ['hookUrl', 'object'],
|
|
148
|
+
} as const;
|
|
149
|
+
|
|
150
|
+
// ── The connector driver: what BOTH kinds implement for the engine core ─────────
|
|
151
|
+
|
|
152
|
+
/** One of their records, already mapped to Loops field names (ownership is the core's job, not the driver's). */
|
|
153
|
+
export interface ExternalRecord {
|
|
154
|
+
object: string;
|
|
155
|
+
externalId: string;
|
|
156
|
+
updatedAt: string | null;
|
|
157
|
+
/** Loops field → value, every mapped field present (null when empty). */
|
|
158
|
+
values: Record<string, unknown>;
|
|
159
|
+
links: { to: RecordKind; role: string | null; externalIds: string[] }[];
|
|
160
|
+
/** SHA-256 of the mapped values (sorted keys): the echo guard and full-mode polling compare this. */
|
|
161
|
+
hash: string;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface ListPage {
|
|
165
|
+
records: ExternalRecord[];
|
|
166
|
+
/** Opaque; pass back for the next page. Null on the last page. */
|
|
167
|
+
next: string | null;
|
|
168
|
+
/** Their total when the API says it. */
|
|
169
|
+
total: number | null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** A change their webhook announced. `record` is filled when the driver could read it (created/updated). */
|
|
173
|
+
export interface HookChange {
|
|
174
|
+
type: 'created' | 'updated' | 'deleted';
|
|
175
|
+
object: string;
|
|
176
|
+
externalId: string;
|
|
177
|
+
record: ExternalRecord | null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The only door a driver has to their API. The core adds the base URL, the credentials (from the vault, refreshed when
|
|
182
|
+
* expired), the rate limit and `Retry-After`; it answers 401/403 by marking the install `needs_auth`. A driver never sees a secret.
|
|
183
|
+
*/
|
|
184
|
+
export interface ConnectorHttp {
|
|
185
|
+
request(method: string, path: string, init?: { query?: Record<string, string>; body?: unknown }): Promise<{ status: number; headers: Headers; json: unknown }>;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export interface ConnectorDriver {
|
|
189
|
+
readonly kind: ConnectorKind;
|
|
190
|
+
/** Their objects this connector syncs, in import order (companies before the people who link to them). */
|
|
191
|
+
objects(): { object: string; ours: RecordKind; fields: { theirs: string; ours: string; owner: 'theirs' | 'ours' }[]; match: string[] }[];
|
|
192
|
+
test(http: ConnectorHttp): Promise<{ ok: boolean; message: string }>;
|
|
193
|
+
list(http: ConnectorHttp, object: string, page: { next: string | null; updatedSince: string | null }): Promise<ListPage>;
|
|
194
|
+
get(http: ConnectorHttp, object: string, externalId: string): Promise<ExternalRecord | null>;
|
|
195
|
+
/** Null when the connector cannot create this object. */
|
|
196
|
+
create(http: ConnectorHttp, object: string, values: Record<string, unknown>): Promise<{ externalId: string } | null>;
|
|
197
|
+
/** `values` holds only Loops-owned fields. False when the connector cannot update this object. */
|
|
198
|
+
update(http: ConnectorHttp, object: string, externalId: string, values: Record<string, unknown>): Promise<boolean>;
|
|
199
|
+
/** Null when the connector does not declare this kind. */
|
|
200
|
+
writeBack(http: ConnectorHttp, target: { object: string; externalId: string }, item: WriteBackItem): Promise<{ externalId: string | null } | null>;
|
|
201
|
+
verifyHook(rawBody: string, headers: Headers, secret: string): Promise<boolean>;
|
|
202
|
+
/** After verifyHook. Reads created/updated records with `get`. */
|
|
203
|
+
parseHook(http: ConnectorHttp, rawBody: string): Promise<HookChange[]>;
|
|
204
|
+
/** Null when the connector does not register webhooks itself. */
|
|
205
|
+
registerHook(http: ConnectorHttp, hookUrl: string): Promise<{ hookId: string | null; secret: string } | null>;
|
|
206
|
+
unregisterHook(http: ConnectorHttp, hookId: string): Promise<void>;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The manifest driver's constructor (`@withloops.ai/contracts/connector`). */
|
|
210
|
+
export type CreateManifestDriver = (sync: SyncManifest) => ConnectorDriver;
|
|
211
|
+
|
|
212
|
+
// ── Engine RPC added in L2 ──────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
export const SyncCaller = z.object({ workspaceId: WorkspaceId, userId: UserId });
|
|
215
|
+
|
|
216
|
+
/** `backend-apps-engine` default WorkerEntrypoint implements this as well as EngineRpc + EngineVaultRpc. */
|
|
217
|
+
export interface EngineSyncRpc {
|
|
218
|
+
syncStatus(installId: string): Promise<SyncStatus | null>;
|
|
219
|
+
/** Save the admin's picks; starts nothing. */
|
|
220
|
+
configureSync(installId: string, settings: SyncSettings): Promise<{ ok: true } | { ok: false; error: string }>;
|
|
221
|
+
previewMapping(installId: string, object: string, limit?: number): Promise<{ ok: true; preview: MappingPreview } | { ok: false; error: string }>;
|
|
222
|
+
/** Starts the first import as a Workflow (resumable). Safe to call twice: a running import is returned, not restarted. */
|
|
223
|
+
startImport(installId: string): Promise<{ ok: true; runId: string } | { ok: false; error: string }>;
|
|
224
|
+
pauseSync(installId: string, paused: boolean): Promise<{ ok: true }>;
|
|
225
|
+
/** Loops wrote something it owns (call, note…) on a record linked to this install; queued, written to their timeline. */
|
|
226
|
+
writeBack(installId: string, record: { kind: z.infer<typeof RecordKind>; loopsId: string }, item: WriteBackItem): Promise<{ ok: true; queued: true }>;
|
|
227
|
+
/** Check the credentials with the connector's test call. */
|
|
228
|
+
testConnection(installId: string): Promise<{ ok: boolean; message: string }>;
|
|
229
|
+
}
|