@likyly/sdk 0.0.0-stage → 1.1.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 (59) hide show
  1. package/README.md +278 -2
  2. package/dist/cjs/client.d.ts +70 -0
  3. package/dist/cjs/client.js +85 -0
  4. package/dist/cjs/encoding.d.ts +11 -0
  5. package/dist/cjs/encoding.js +37 -0
  6. package/dist/cjs/errors.d.ts +52 -0
  7. package/dist/cjs/errors.js +83 -0
  8. package/dist/cjs/http.d.ts +49 -0
  9. package/dist/cjs/http.js +155 -0
  10. package/dist/cjs/index.d.ts +8 -0
  11. package/dist/cjs/index.js +20 -0
  12. package/dist/cjs/package.json +1 -0
  13. package/dist/cjs/recommend-cache.d.ts +42 -0
  14. package/dist/cjs/recommend-cache.js +114 -0
  15. package/dist/cjs/resources/events.d.ts +45 -0
  16. package/dist/cjs/resources/events.js +134 -0
  17. package/dist/cjs/resources/items.d.ts +43 -0
  18. package/dist/cjs/resources/items.js +97 -0
  19. package/dist/cjs/resources/placements.d.ts +24 -0
  20. package/dist/cjs/resources/placements.js +97 -0
  21. package/dist/cjs/resources/recommendations.d.ts +54 -0
  22. package/dist/cjs/resources/recommendations.js +130 -0
  23. package/dist/cjs/resources/users.d.ts +19 -0
  24. package/dist/cjs/resources/users.js +64 -0
  25. package/dist/cjs/session.d.ts +32 -0
  26. package/dist/cjs/session.js +76 -0
  27. package/dist/cjs/types.d.ts +226 -0
  28. package/dist/cjs/types.js +2 -0
  29. package/dist/cjs/version.d.ts +3 -0
  30. package/dist/cjs/version.js +6 -0
  31. package/dist/esm/client.d.ts +70 -0
  32. package/dist/esm/client.js +81 -0
  33. package/dist/esm/encoding.d.ts +11 -0
  34. package/dist/esm/encoding.js +31 -0
  35. package/dist/esm/errors.d.ts +52 -0
  36. package/dist/esm/errors.js +69 -0
  37. package/dist/esm/http.d.ts +49 -0
  38. package/dist/esm/http.js +149 -0
  39. package/dist/esm/index.d.ts +8 -0
  40. package/dist/esm/index.js +4 -0
  41. package/dist/esm/recommend-cache.d.ts +42 -0
  42. package/dist/esm/recommend-cache.js +110 -0
  43. package/dist/esm/resources/events.d.ts +45 -0
  44. package/dist/esm/resources/events.js +130 -0
  45. package/dist/esm/resources/items.d.ts +43 -0
  46. package/dist/esm/resources/items.js +91 -0
  47. package/dist/esm/resources/placements.d.ts +24 -0
  48. package/dist/esm/resources/placements.js +93 -0
  49. package/dist/esm/resources/recommendations.d.ts +54 -0
  50. package/dist/esm/resources/recommendations.js +124 -0
  51. package/dist/esm/resources/users.d.ts +19 -0
  52. package/dist/esm/resources/users.js +60 -0
  53. package/dist/esm/session.d.ts +32 -0
  54. package/dist/esm/session.js +72 -0
  55. package/dist/esm/types.d.ts +226 -0
  56. package/dist/esm/types.js +1 -0
  57. package/dist/esm/version.d.ts +3 -0
  58. package/dist/esm/version.js +3 -0
  59. package/package.json +40 -4
@@ -0,0 +1,149 @@
1
+ import { LikylyError, NetworkError, TimeoutError, ValidationError, errorFromResponse, } from "./errors.js";
2
+ const RETRY_BASE_MS = 500;
3
+ const RETRY_CAP_MS = 8_000;
4
+ /** A Retry-After longer than this is not waited for: the RateLimitError is thrown instead. */
5
+ const MAX_RETRY_AFTER_MS = 60_000;
6
+ /**
7
+ * The only place that talks HTTP. Every resource goes through `request()`: auth header, catalog
8
+ * parameter, timeout, retries with exponential backoff + jitter, and the error mapping.
9
+ */
10
+ export class HttpClient {
11
+ config;
12
+ constructor(config) {
13
+ this.config = config;
14
+ }
15
+ async request(req) {
16
+ const maxRetries = req.options?.maxRetries ?? this.config.maxRetries;
17
+ const url = this.buildUrl(req);
18
+ let attempt = 0;
19
+ for (;;) {
20
+ let failure;
21
+ try {
22
+ return await this.attempt(req, url);
23
+ }
24
+ catch (error) {
25
+ if (!(error instanceof LikylyError))
26
+ throw error; // e.g. the caller's own AbortError
27
+ failure = error;
28
+ }
29
+ const delay = this.retryDelay(failure, attempt, req.idempotent);
30
+ if (delay === undefined || attempt >= maxRetries)
31
+ throw failure;
32
+ attempt += 1;
33
+ await this.config.sleep(delay);
34
+ }
35
+ }
36
+ /** Milliseconds to wait before the next attempt, or undefined if this failure must not be retried. */
37
+ retryDelay(error, attempt, idempotent) {
38
+ const backoff = () => this.config.random() * Math.min(RETRY_CAP_MS, RETRY_BASE_MS * 2 ** attempt); // full jitter
39
+ if (error.statusCode === 429) {
40
+ // Rejected by the rate limiter before reaching the application: nothing was processed, so
41
+ // retrying is safe for every request.
42
+ if (error.retryAfter !== undefined) {
43
+ const wait = error.retryAfter * 1000;
44
+ return wait <= MAX_RETRY_AFTER_MS ? wait : undefined;
45
+ }
46
+ return backoff();
47
+ }
48
+ if (!idempotent)
49
+ return undefined; // the outcome is unknown - never risk a duplicate
50
+ if (error.statusCode === 502 || error.statusCode === 503 || error.statusCode === 504) {
51
+ if (error.retryAfter !== undefined) {
52
+ const wait = error.retryAfter * 1000;
53
+ return wait <= MAX_RETRY_AFTER_MS ? wait : undefined;
54
+ }
55
+ return backoff();
56
+ }
57
+ if (error instanceof NetworkError || error instanceof TimeoutError)
58
+ return backoff();
59
+ return undefined;
60
+ }
61
+ buildUrl(req) {
62
+ const params = new URLSearchParams();
63
+ for (const [key, value] of Object.entries(req.query ?? {})) {
64
+ if (value !== undefined)
65
+ params.set(key, String(value));
66
+ }
67
+ if (this.config.catalog)
68
+ params.set("data_product_type", this.config.catalog);
69
+ const qs = params.toString();
70
+ return this.config.baseUrl + req.path + (qs ? `?${qs}` : "");
71
+ }
72
+ async attempt(req, url) {
73
+ const timeoutMs = req.options?.timeout ?? this.config.timeout;
74
+ const controller = new AbortController();
75
+ const userSignal = req.options?.signal;
76
+ let timedOut = false;
77
+ const timer = setTimeout(() => {
78
+ timedOut = true;
79
+ controller.abort();
80
+ }, timeoutMs);
81
+ const onUserAbort = () => controller.abort();
82
+ if (userSignal) {
83
+ if (userSignal.aborted)
84
+ controller.abort();
85
+ else
86
+ userSignal.addEventListener("abort", onUserAbort, { once: true });
87
+ }
88
+ const headers = {
89
+ "X-API-Key": this.config.apiKey,
90
+ Accept: "application/json",
91
+ "User-Agent": this.config.userAgent,
92
+ };
93
+ if (req.body !== undefined)
94
+ headers["Content-Type"] = "application/json";
95
+ let response;
96
+ try {
97
+ response = await this.config.fetch(url, {
98
+ method: req.method,
99
+ headers,
100
+ body: req.body !== undefined ? JSON.stringify(req.body) : undefined,
101
+ signal: controller.signal,
102
+ });
103
+ }
104
+ catch (cause) {
105
+ if (timedOut)
106
+ throw new TimeoutError(`LIKYLY request timed out after ${timeoutMs} ms`, { cause });
107
+ if (userSignal?.aborted)
108
+ throw cause; // the caller cancelled: surface their AbortError untouched
109
+ throw new NetworkError(`Could not reach the LIKYLY API: ${cause?.message ?? cause}`, { cause });
110
+ }
111
+ finally {
112
+ clearTimeout(timer);
113
+ userSignal?.removeEventListener("abort", onUserAbort);
114
+ }
115
+ const text = await response.text().catch(() => "");
116
+ let data = undefined;
117
+ if (text) {
118
+ try {
119
+ data = JSON.parse(text);
120
+ }
121
+ catch {
122
+ data = text; // e.g. an HTML 502 page from a proxy
123
+ }
124
+ }
125
+ const requestId = response.headers.get("x-request-id") ??
126
+ (data && typeof data === "object" && "request_id" in data ? String(data.request_id) : undefined);
127
+ if (!response.ok) {
128
+ throw errorFromResponse(response.status, data, requestId ?? undefined, parseRetryAfter(response.headers.get("retry-after")));
129
+ }
130
+ return { data: data, status: response.status, headers: response.headers, requestId: requestId ?? undefined };
131
+ }
132
+ }
133
+ /** Retry-After is either a number of seconds or an HTTP date. */
134
+ export function parseRetryAfter(value) {
135
+ if (!value)
136
+ return undefined;
137
+ const seconds = Number(value);
138
+ if (Number.isFinite(seconds) && seconds >= 0)
139
+ return seconds;
140
+ const date = Date.parse(value);
141
+ if (!Number.isNaN(date))
142
+ return Math.max(0, Math.ceil((date - Date.now()) / 1000));
143
+ return undefined;
144
+ }
145
+ export function assertNonEmptyObject(value, name) {
146
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
147
+ throw new ValidationError(`${name} must be an object`);
148
+ }
149
+ }
@@ -0,0 +1,8 @@
1
+ export { Likyly } from "./client.js";
2
+ export type { LikylyOptions } from "./client.js";
3
+ export { LikylySession } from "./session.js";
4
+ export type { LikylySessionOptions } from "./session.js";
5
+ export { LikylyError, ApiError, AuthenticationError, PermissionDeniedError, NotFoundError, RateLimitError, ValidationError, NetworkError, TimeoutError, PausedError, } from "./errors.js";
6
+ export type { RecommendCacheOptions } from "./recommend-cache.js";
7
+ export { SDK_VERSION } from "./version.js";
8
+ export type * from "./types.js";
@@ -0,0 +1,4 @@
1
+ export { Likyly } from "./client.js";
2
+ export { LikylySession } from "./session.js";
3
+ export { LikylyError, ApiError, AuthenticationError, PermissionDeniedError, NotFoundError, RateLimitError, ValidationError, NetworkError, TimeoutError, PausedError, } from "./errors.js";
4
+ export { SDK_VERSION } from "./version.js";
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Client-side protection for `placements.recommend`, so a page does not call LIKYLY on every
3
+ * view and does not keep hammering it when it is down:
4
+ *
5
+ * - a cache: an answer is reused for `ttlSeconds` (default one hour) for the same placement,
6
+ * item and visitor. In a browser it lives in `sessionStorage` (one visitor's tab session:
7
+ * back/forward and revisits cost nothing); elsewhere (SSR, Node) in memory. A reused answer
8
+ * keeps its `recommendationId`, so impressions and clicks stay attributed.
9
+ * - a circuit breaker: after a network error, a timeout or a 5xx, calls fail at once for
10
+ * `pauseSeconds` (default 30) instead of waiting on LIKYLY, so the page shows its own
11
+ * fallback straight away. The next call after the pause tries again.
12
+ */
13
+ import type { PlacementRecommendResponse } from "./types.js";
14
+ export interface RecommendCacheOptions {
15
+ /** How long an answer is reused, in seconds. Default 3600. */
16
+ ttlSeconds?: number;
17
+ /** After LIKYLY fails, how long to skip calls, in seconds. Default 30. Set 0 to always call. */
18
+ pauseSeconds?: number;
19
+ /** Where answers are kept. Default: "session" in a browser (sessionStorage), "memory" elsewhere. */
20
+ storage?: "session" | "memory";
21
+ }
22
+ export declare class RecommendCache {
23
+ private readonly now;
24
+ private readonly ttlMs;
25
+ private readonly pauseMs;
26
+ private readonly storage;
27
+ private readonly memory;
28
+ private pausedUntil;
29
+ constructor(options?: RecommendCacheOptions, now?: () => number);
30
+ get(key: string): PlacementRecommendResponse | null;
31
+ set(key: string, value: PlacementRecommendResponse): void;
32
+ /** Drop every cached answer of this client (e.g. after a purge in the LIKYLY console). */
33
+ clear(): void;
34
+ /** True while calls are paused after a failure. */
35
+ isPaused(): boolean;
36
+ /** Seconds left in the pause (0 when not paused). */
37
+ pauseRemaining(): number;
38
+ recordFailure(): void;
39
+ recordSuccess(): void;
40
+ private read;
41
+ private remove;
42
+ }
@@ -0,0 +1,110 @@
1
+ const PREFIX = "likyly:rec:";
2
+ const MAX_MEMORY_ENTRIES = 500;
3
+ function browserStorage() {
4
+ try {
5
+ const storage = globalThis.sessionStorage;
6
+ if (!storage)
7
+ return null;
8
+ storage.getItem(PREFIX); // throws where storage is blocked (privacy settings, sandboxed frames)
9
+ return storage;
10
+ }
11
+ catch {
12
+ return null;
13
+ }
14
+ }
15
+ export class RecommendCache {
16
+ now;
17
+ ttlMs;
18
+ pauseMs;
19
+ storage;
20
+ memory = new Map();
21
+ pausedUntil = 0;
22
+ constructor(options = {}, now = Date.now) {
23
+ this.now = now;
24
+ this.ttlMs = Math.max(0, options.ttlSeconds ?? 3600) * 1000;
25
+ this.pauseMs = Math.max(0, options.pauseSeconds ?? 30) * 1000;
26
+ this.storage = options.storage === "memory" ? null : browserStorage();
27
+ }
28
+ get(key) {
29
+ if (this.ttlMs === 0)
30
+ return null;
31
+ const entry = this.read(key);
32
+ if (!entry)
33
+ return null;
34
+ if (entry.expires <= this.now()) {
35
+ this.remove(key);
36
+ return null;
37
+ }
38
+ return entry.value;
39
+ }
40
+ set(key, value) {
41
+ if (this.ttlMs === 0)
42
+ return;
43
+ const entry = { expires: this.now() + this.ttlMs, value };
44
+ if (this.storage) {
45
+ try {
46
+ this.storage.setItem(PREFIX + key, JSON.stringify(entry));
47
+ return;
48
+ }
49
+ catch {
50
+ // Storage full or blocked: fall back to memory for this one.
51
+ }
52
+ }
53
+ this.memory.set(key, entry);
54
+ if (this.memory.size > MAX_MEMORY_ENTRIES)
55
+ this.memory.delete(this.memory.keys().next().value);
56
+ }
57
+ /** Drop every cached answer of this client (e.g. after a purge in the LIKYLY console). */
58
+ clear() {
59
+ this.memory.clear();
60
+ if (!this.storage)
61
+ return;
62
+ try {
63
+ for (let i = this.storage.length - 1; i >= 0; i--) {
64
+ const k = this.storage.key(i);
65
+ if (k?.startsWith(PREFIX))
66
+ this.storage.removeItem(k);
67
+ }
68
+ }
69
+ catch {
70
+ // Blocked storage: nothing was written there either.
71
+ }
72
+ }
73
+ /** True while calls are paused after a failure. */
74
+ isPaused() {
75
+ return this.now() < this.pausedUntil;
76
+ }
77
+ /** Seconds left in the pause (0 when not paused). */
78
+ pauseRemaining() {
79
+ return Math.max(0, Math.ceil((this.pausedUntil - this.now()) / 1000));
80
+ }
81
+ recordFailure() {
82
+ if (this.pauseMs > 0)
83
+ this.pausedUntil = this.now() + this.pauseMs;
84
+ }
85
+ recordSuccess() {
86
+ this.pausedUntil = 0;
87
+ }
88
+ read(key) {
89
+ if (this.storage) {
90
+ try {
91
+ const raw = this.storage.getItem(PREFIX + key);
92
+ if (raw)
93
+ return JSON.parse(raw);
94
+ }
95
+ catch {
96
+ // Unreadable entry or storage: treat as a miss.
97
+ }
98
+ }
99
+ return this.memory.get(key) ?? null;
100
+ }
101
+ remove(key) {
102
+ this.memory.delete(key);
103
+ try {
104
+ this.storage?.removeItem(PREFIX + key);
105
+ }
106
+ catch {
107
+ // ignore
108
+ }
109
+ }
110
+ }
@@ -0,0 +1,45 @@
1
+ import type { HttpClient } from "../http.js";
2
+ import type { EventBatchResult, EventInput, EventResult, IdentifyInput, IdentifyResult, RequestOptions, TypedEventInput } from "../types.js";
3
+ /**
4
+ * `likyly.events` - what your visitors do. Works with the **public** key from a browser, or the
5
+ * secret key from your backend. `track()` is the one mechanism; `view()`, `click()`, ... are
6
+ * shortcuts that call it with the matching event type.
7
+ */
8
+ export declare class EventsResource {
9
+ private readonly http;
10
+ constructor(http: HttpClient);
11
+ /**
12
+ * Records one event of any type: `view`, `click`, ... or your own (`favorite`, `share`, ...).
13
+ * Event types are open strings - the six helpers below are conveniences, not a closed list.
14
+ *
15
+ * Retries: a failed call is only retried automatically when it carries an `eventId` (then a
16
+ * replay is harmless); without one, an ambiguous failure is thrown rather than risking a duplicate.
17
+ */
18
+ track(type: string, event: EventInput, options?: RequestOptions): Promise<EventResult>;
19
+ /** Up to 1000 events in one call, each with its own `type`. All-or-nothing validation. */
20
+ trackMany(events: TypedEventInput[], options?: RequestOptions): Promise<EventBatchResult>;
21
+ /** The item was shown to the visitor (send the `recommendationId` it came with). */
22
+ impression(event: EventInput, options?: RequestOptions): Promise<EventResult>;
23
+ /** The visitor looked at the item (a product page, an article). */
24
+ view(event: EventInput, options?: RequestOptions): Promise<EventResult>;
25
+ /** The visitor clicked the item. */
26
+ click(event: EventInput, options?: RequestOptions): Promise<EventResult>;
27
+ addToCart(event: EventInput, options?: RequestOptions): Promise<EventResult>;
28
+ removeFromCart(event: EventInput, options?: RequestOptions): Promise<EventResult>;
29
+ /** Set `eventId` (e.g. `purchase_<orderId>_<itemId>`) so a retry can never count the purchase twice. */
30
+ purchase(event: EventInput, options?: RequestOptions): Promise<EventResult>;
31
+ /** Alias of {@link impression} - the item was shown to the visitor in a recommendation slot. */
32
+ recommendationImpression(event: EventInput, options?: RequestOptions): Promise<EventResult>;
33
+ /** Alias of {@link click} - the visitor clicked a recommended item. */
34
+ recommendationClick(event: EventInput, options?: RequestOptions): Promise<EventResult>;
35
+ /** Alias of {@link view} - the visitor looked at a product/article page. */
36
+ productView(event: EventInput, options?: RequestOptions): Promise<EventResult>;
37
+ /**
38
+ * Call right after login/signup, once you know `userId`, for the `sessionId` the visitor
39
+ * was anonymous under: every past interaction of that session not already attributed to a
40
+ * user is reassigned to `userId`, so it counts as that user's history from then on. Safe to
41
+ * call more than once. See `LikylySession.identify` for the client-side counterpart that
42
+ * also remembers the link locally.
43
+ */
44
+ identify(input: IdentifyInput, options?: RequestOptions): Promise<IdentifyResult>;
45
+ }
@@ -0,0 +1,130 @@
1
+ import { compact, encodeSegment, requireId } from "../encoding.js";
2
+ import { ValidationError } from "../errors.js";
3
+ /**
4
+ * `likyly.events` - what your visitors do. Works with the **public** key from a browser, or the
5
+ * secret key from your backend. `track()` is the one mechanism; `view()`, `click()`, ... are
6
+ * shortcuts that call it with the matching event type.
7
+ */
8
+ export class EventsResource {
9
+ http;
10
+ constructor(http) {
11
+ this.http = http;
12
+ }
13
+ /**
14
+ * Records one event of any type: `view`, `click`, ... or your own (`favorite`, `share`, ...).
15
+ * Event types are open strings - the six helpers below are conveniences, not a closed list.
16
+ *
17
+ * Retries: a failed call is only retried automatically when it carries an `eventId` (then a
18
+ * replay is harmless); without one, an ambiguous failure is thrown rather than risking a duplicate.
19
+ */
20
+ async track(type, event, options) {
21
+ const res = await this.http.request({
22
+ method: "POST",
23
+ path: `/events/${encodeSegment(requireEventType(type))}`,
24
+ body: eventBody(event),
25
+ idempotent: event.eventId !== undefined,
26
+ options,
27
+ });
28
+ return { message: res.data.message, eventId: res.data.event_id ?? undefined, duplicate: res.data.duplicate === true };
29
+ }
30
+ /** Up to 1000 events in one call, each with its own `type`. All-or-nothing validation. */
31
+ async trackMany(events, options) {
32
+ if (!Array.isArray(events) || events.length === 0)
33
+ throw new ValidationError("events must be a non-empty array");
34
+ const res = await this.http.request({
35
+ method: "POST",
36
+ path: "/events/batch",
37
+ body: { events: events.map((e) => ({ event_type: requireEventType(e.type), ...eventBody(e) })) },
38
+ idempotent: events.every((e) => e.eventId !== undefined),
39
+ options,
40
+ });
41
+ return { received: res.data.received, accepted: res.data.accepted, duplicates: res.data.duplicates };
42
+ }
43
+ /** The item was shown to the visitor (send the `recommendationId` it came with). */
44
+ impression(event, options) {
45
+ return this.track("impression", event, options);
46
+ }
47
+ /** The visitor looked at the item (a product page, an article). */
48
+ view(event, options) {
49
+ return this.track("view", event, options);
50
+ }
51
+ /** The visitor clicked the item. */
52
+ click(event, options) {
53
+ return this.track("click", event, options);
54
+ }
55
+ addToCart(event, options) {
56
+ return this.track("add_to_cart", event, options);
57
+ }
58
+ removeFromCart(event, options) {
59
+ return this.track("remove_from_cart", event, options);
60
+ }
61
+ /** Set `eventId` (e.g. `purchase_<orderId>_<itemId>`) so a retry can never count the purchase twice. */
62
+ purchase(event, options) {
63
+ return this.track("purchase", event, options);
64
+ }
65
+ // ---- Clearer aliases for the recommendation-tracking events ---------------------------
66
+ // Same wire call as impression/view/click above (mapped server-side onto the same
67
+ // already-weighted event type - see db.EVENT_TYPE_ALIASES) - purely a naming preference,
68
+ // e.g. for code a coding agent generates from the LIKYLY MCP tools' own vocabulary.
69
+ /** Alias of {@link impression} - the item was shown to the visitor in a recommendation slot. */
70
+ recommendationImpression(event, options) {
71
+ return this.track("recommendation_impression", event, options);
72
+ }
73
+ /** Alias of {@link click} - the visitor clicked a recommended item. */
74
+ recommendationClick(event, options) {
75
+ return this.track("recommendation_click", event, options);
76
+ }
77
+ /** Alias of {@link view} - the visitor looked at a product/article page. */
78
+ productView(event, options) {
79
+ return this.track("product_view", event, options);
80
+ }
81
+ /**
82
+ * Call right after login/signup, once you know `userId`, for the `sessionId` the visitor
83
+ * was anonymous under: every past interaction of that session not already attributed to a
84
+ * user is reassigned to `userId`, so it counts as that user's history from then on. Safe to
85
+ * call more than once. See `LikylySession.identify` for the client-side counterpart that
86
+ * also remembers the link locally.
87
+ */
88
+ async identify(input, options) {
89
+ const userId = requireId(input.userId, "userId");
90
+ const sessionId = requireId(input.sessionId, "sessionId");
91
+ const res = await this.http.request({
92
+ method: "POST",
93
+ path: "/events/identify",
94
+ body: { user_id: userId, session_id: sessionId },
95
+ idempotent: true, // only ever reassigns not-yet-attributed rows - replaying is harmless
96
+ options,
97
+ });
98
+ return { linkedInteractions: res.data.linked_interactions };
99
+ }
100
+ }
101
+ function requireEventType(type) {
102
+ if (typeof type !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(type)) {
103
+ throw new ValidationError('event type must be 1-64 characters of letters, digits, "_" or "-" (e.g. "view", "add_to_cart", "favorite")');
104
+ }
105
+ return type;
106
+ }
107
+ /** `properties` is passed through untouched: its keys are yours. */
108
+ function eventBody(event) {
109
+ if (event === null || typeof event !== "object")
110
+ throw new ValidationError("event must be an object");
111
+ const itemId = requireId(event.itemId, "itemId");
112
+ if (event.userId === undefined && event.sessionId === undefined) {
113
+ throw new ValidationError("an event needs a userId or a sessionId (or both)");
114
+ }
115
+ if (event.userId !== undefined)
116
+ requireId(event.userId, "userId");
117
+ if (event.sessionId !== undefined)
118
+ requireId(event.sessionId, "sessionId");
119
+ return compact({
120
+ event_id: event.eventId,
121
+ user_id: event.userId,
122
+ session_id: event.sessionId,
123
+ item_id: itemId,
124
+ recommendation_id: event.recommendationId,
125
+ placement: event.placement,
126
+ quantity: event.quantity,
127
+ occurred_at: event.occurredAt instanceof Date ? event.occurredAt.toISOString() : event.occurredAt,
128
+ properties: event.properties,
129
+ });
130
+ }
@@ -0,0 +1,43 @@
1
+ import type { HttpClient } from "../http.js";
2
+ import type { BatchResult, Item, ItemImport, ItemInput, ItemList, ListOptions, RequestOptions } from "../types.js";
3
+ /** `likyly.items` - your catalog. Needs the **secret** API key. */
4
+ export declare class ItemsResource {
5
+ private readonly http;
6
+ constructor(http: HttpClient);
7
+ /** One item by your own id. */
8
+ get(itemId: string, options?: RequestOptions): Promise<Item>;
9
+ /** One page of the catalog. Pagination is `limit` + `offset` (the API's default is 100 per page); `total` is the whole catalog's size. */
10
+ list(listOptions?: ListOptions, options?: RequestOptions): Promise<ItemList>;
11
+ /**
12
+ * Creates the item, or replaces it if it exists - idempotent, safe to call as often as you like.
13
+ * The body is the whole item: fields you leave out are cleared.
14
+ */
15
+ upsert(itemId: string, item: ItemInput, options?: RequestOptions): Promise<Item>;
16
+ /** Removes the item from the catalog. Events already recorded for it are kept. */
17
+ delete(itemId: string, options?: RequestOptions): Promise<void>;
18
+ /** Batch upsert (1-1000 items) - each entry behaves like `upsert`. A failing entry is reported in `errors`. */
19
+ upsertMany(items: ItemImport[], options?: RequestOptions): Promise<BatchResult>;
20
+ /** Alias of {@link upsertMany}: the API's `POST /items/import` is a JSON batch upsert. (A CSV import exists only in the LIKYLY dashboard.) */
21
+ import(items: ItemImport[], options?: RequestOptions): Promise<BatchResult>;
22
+ /** Batch delete (1-1000 ids). Ids that don't exist are reported in `errors`. */
23
+ deleteMany(itemIds: string[], options?: RequestOptions): Promise<BatchResult>;
24
+ }
25
+ interface WireItem {
26
+ item_id: string;
27
+ title: string;
28
+ description?: string | null;
29
+ properties?: Record<string, unknown>;
30
+ }
31
+ export interface WireBatch {
32
+ received: number;
33
+ succeeded: number;
34
+ failed: number;
35
+ errors?: {
36
+ index: number;
37
+ id?: string | null;
38
+ message: string;
39
+ }[];
40
+ }
41
+ export declare function toItem(w: WireItem): Item;
42
+ export declare function toBatch(w: WireBatch): BatchResult;
43
+ export {};
@@ -0,0 +1,91 @@
1
+ import { compact, encodeSegment, requireId } from "../encoding.js";
2
+ import { ValidationError } from "../errors.js";
3
+ /** `likyly.items` - your catalog. Needs the **secret** API key. */
4
+ export class ItemsResource {
5
+ http;
6
+ constructor(http) {
7
+ this.http = http;
8
+ }
9
+ /** One item by your own id. */
10
+ async get(itemId, options) {
11
+ const res = await this.http.request({ method: "GET", path: `/items/${encodeSegment(requireId(itemId, "itemId"))}`, idempotent: true, options });
12
+ return toItem(res.data);
13
+ }
14
+ /** One page of the catalog. Pagination is `limit` + `offset` (the API's default is 100 per page); `total` is the whole catalog's size. */
15
+ async list(listOptions = {}, options) {
16
+ const res = await this.http.request({
17
+ method: "GET",
18
+ path: "/items",
19
+ query: { limit: listOptions.limit, offset: listOptions.offset },
20
+ idempotent: true,
21
+ options,
22
+ });
23
+ const total = res.headers.get("x-total-count");
24
+ return { items: res.data.map(toItem), total: total === null ? undefined : Number(total), limit: listOptions.limit, offset: listOptions.offset ?? 0 };
25
+ }
26
+ /**
27
+ * Creates the item, or replaces it if it exists - idempotent, safe to call as often as you like.
28
+ * The body is the whole item: fields you leave out are cleared.
29
+ */
30
+ async upsert(itemId, item, options) {
31
+ const res = await this.http.request({
32
+ method: "PUT",
33
+ path: `/items/${encodeSegment(requireId(itemId, "itemId"))}`,
34
+ body: itemBody(item),
35
+ idempotent: true,
36
+ options,
37
+ });
38
+ return toItem(res.data);
39
+ }
40
+ /** Removes the item from the catalog. Events already recorded for it are kept. */
41
+ async delete(itemId, options) {
42
+ await this.http.request({ method: "DELETE", path: `/items/${encodeSegment(requireId(itemId, "itemId"))}`, idempotent: true, options });
43
+ }
44
+ /** Batch upsert (1-1000 items) - each entry behaves like `upsert`. A failing entry is reported in `errors`. */
45
+ async upsertMany(items, options) {
46
+ if (!Array.isArray(items) || items.length === 0)
47
+ throw new ValidationError("items must be a non-empty array");
48
+ const res = await this.http.request({
49
+ method: "POST",
50
+ path: "/items/import",
51
+ body: { items: items.map((i) => ({ item_id: requireId(i.itemId, "itemId"), ...itemBody(i) })) },
52
+ idempotent: true, // an upsert: replaying it changes nothing
53
+ options,
54
+ });
55
+ return toBatch(res.data);
56
+ }
57
+ /** Alias of {@link upsertMany}: the API's `POST /items/import` is a JSON batch upsert. (A CSV import exists only in the LIKYLY dashboard.) */
58
+ import(items, options) {
59
+ return this.upsertMany(items, options);
60
+ }
61
+ /** Batch delete (1-1000 ids). Ids that don't exist are reported in `errors`. */
62
+ async deleteMany(itemIds, options) {
63
+ if (!Array.isArray(itemIds) || itemIds.length === 0)
64
+ throw new ValidationError("itemIds must be a non-empty array");
65
+ const res = await this.http.request({
66
+ method: "POST",
67
+ path: "/items/delete",
68
+ body: { item_ids: itemIds.map((id) => requireId(id, "itemId")) },
69
+ idempotent: true,
70
+ options,
71
+ });
72
+ return toBatch(res.data);
73
+ }
74
+ }
75
+ function itemBody(item) {
76
+ if (item === null || typeof item !== "object" || typeof item.title !== "string" || item.title === "") {
77
+ throw new ValidationError("an item needs a non-empty title");
78
+ }
79
+ return compact({ title: item.title, description: item.description, properties: item.properties });
80
+ }
81
+ export function toItem(w) {
82
+ return { itemId: w.item_id, title: w.title, description: w.description ?? undefined, properties: w.properties ?? {} };
83
+ }
84
+ export function toBatch(w) {
85
+ return {
86
+ received: w.received,
87
+ succeeded: w.succeeded,
88
+ failed: w.failed,
89
+ errors: (w.errors ?? []).map((e) => ({ index: e.index, id: e.id ?? undefined, message: e.message })),
90
+ };
91
+ }
@@ -0,0 +1,24 @@
1
+ import type { HttpClient } from "../http.js";
2
+ import type { PlacementRecommendOptions, PlacementRecommendResponse, RequestOptions } from "../types.js";
3
+ import type { RecommendCache } from "../recommend-cache.js";
4
+ /**
5
+ * `likyly.placements` - the recommended way to ask for recommendations. A Placement ("what to
6
+ * recommend, where, with what fallback and filters") is configured once - via the LIKYLY MCP
7
+ * admin tools or dashboard, never from this runtime SDK - and called by `slug` from here on.
8
+ * `likyly.recommendations` (the strategy-specific / signal-based API) remains available for
9
+ * advanced, per-call use with no persistent configuration.
10
+ */
11
+ export declare class PlacementsResource {
12
+ private readonly http;
13
+ /** null when the client was built with `cache: false`. */
14
+ readonly cache: RecommendCache | null;
15
+ constructor(http: HttpClient,
16
+ /** null when the client was built with `cache: false`. */
17
+ cache?: RecommendCache | null);
18
+ /**
19
+ * Recommendations for one placement: send whatever context you have (`itemId`, `userId`,
20
+ * `sessionId`, ...) - the placement's own configuration decides the strategy, fallback and
21
+ * filters, not you. Send `recommendationId` back on the events that follow to attribute them.
22
+ */
23
+ recommend(opts: PlacementRecommendOptions, options?: RequestOptions): Promise<PlacementRecommendResponse>;
24
+ }