@jaypie/dynamodb 0.0.1 → 0.1.1

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.
@@ -0,0 +1,1084 @@
1
+ import { registerMcpTool } from '@jaypie/vocabulary/mcp';
2
+ import { serviceHandler } from '@jaypie/vocabulary';
3
+ import { DynamoDBClient, DescribeTableCommand, CreateTableCommand } from '@aws-sdk/client-dynamodb';
4
+ import { DynamoDBDocumentClient, GetCommand, PutCommand, DeleteCommand, QueryCommand } from '@aws-sdk/lib-dynamodb';
5
+ import { ConfigurationError } from '@jaypie/errors';
6
+
7
+ const DEFAULT_REGION$2 = "us-east-1";
8
+ const LOCAL_CREDENTIALS = {
9
+ accessKeyId: "local",
10
+ secretAccessKey: "local",
11
+ };
12
+ // Module-level state
13
+ let docClient = null;
14
+ let tableName = null;
15
+ /**
16
+ * Check if endpoint indicates local development mode
17
+ */
18
+ function isLocalEndpoint(endpoint) {
19
+ if (!endpoint)
20
+ return false;
21
+ return endpoint.includes("127.0.0.1") || endpoint.includes("localhost");
22
+ }
23
+ /**
24
+ * Initialize the DynamoDB client
25
+ * Must be called once at application startup before using query functions
26
+ *
27
+ * @param config - Client configuration
28
+ */
29
+ function initClient(config) {
30
+ const { endpoint, region = DEFAULT_REGION$2 } = config;
31
+ // Auto-detect local mode and use dummy credentials
32
+ const credentials = config.credentials ??
33
+ (isLocalEndpoint(endpoint) ? LOCAL_CREDENTIALS : undefined);
34
+ const dynamoClient = new DynamoDBClient({
35
+ ...(credentials && { credentials }),
36
+ ...(endpoint && { endpoint }),
37
+ region,
38
+ });
39
+ docClient = DynamoDBDocumentClient.from(dynamoClient, {
40
+ marshallOptions: {
41
+ removeUndefinedValues: true,
42
+ },
43
+ });
44
+ tableName = config.tableName;
45
+ }
46
+ /**
47
+ * Get the initialized DynamoDB Document Client
48
+ * @throws ConfigurationError if client has not been initialized
49
+ */
50
+ function getDocClient() {
51
+ if (!docClient) {
52
+ throw new ConfigurationError("DynamoDB client not initialized. Call initClient() first.");
53
+ }
54
+ return docClient;
55
+ }
56
+ /**
57
+ * Get the configured table name
58
+ * @throws ConfigurationError if client has not been initialized
59
+ */
60
+ function getTableName() {
61
+ if (!tableName) {
62
+ throw new ConfigurationError("DynamoDB client not initialized. Call initClient() first.");
63
+ }
64
+ return tableName;
65
+ }
66
+ /**
67
+ * Check if the client has been initialized
68
+ */
69
+ function isInitialized() {
70
+ return docClient !== null && tableName !== null;
71
+ }
72
+
73
+ // Primary markers
74
+ const SEPARATOR = "#"; // Composite key separator
75
+ // GSI names
76
+ const INDEX_ALIAS = "indexAlias";
77
+ const INDEX_CLASS = "indexClass";
78
+ const INDEX_OU = "indexOu";
79
+ const INDEX_TYPE = "indexType";
80
+ const INDEX_XID = "indexXid";
81
+ // Index suffixes for soft state
82
+ const ARCHIVED_SUFFIX = "#archived";
83
+ const DELETED_SUFFIX = "#deleted";
84
+
85
+ /**
86
+ * Build the indexOu key for hierarchical queries
87
+ * @param ou - The organizational unit (APEX or "{parent.model}#{parent.id}")
88
+ * @param model - The entity model name
89
+ * @returns Composite key: "{ou}#{model}"
90
+ */
91
+ function buildIndexOu(ou, model) {
92
+ return `${ou}${SEPARATOR}${model}`;
93
+ }
94
+ /**
95
+ * Build the indexAlias key for human-friendly lookups
96
+ * @param ou - The organizational unit
97
+ * @param model - The entity model name
98
+ * @param alias - The human-friendly alias
99
+ * @returns Composite key: "{ou}#{model}#{alias}"
100
+ */
101
+ function buildIndexAlias(ou, model, alias) {
102
+ return `${ou}${SEPARATOR}${model}${SEPARATOR}${alias}`;
103
+ }
104
+ /**
105
+ * Build the indexClass key for category filtering
106
+ * @param ou - The organizational unit
107
+ * @param model - The entity model name
108
+ * @param recordClass - The category classification
109
+ * @returns Composite key: "{ou}#{model}#{class}"
110
+ */
111
+ function buildIndexClass(ou, model, recordClass) {
112
+ return `${ou}${SEPARATOR}${model}${SEPARATOR}${recordClass}`;
113
+ }
114
+ /**
115
+ * Build the indexType key for type filtering
116
+ * @param ou - The organizational unit
117
+ * @param model - The entity model name
118
+ * @param type - The type classification
119
+ * @returns Composite key: "{ou}#{model}#{type}"
120
+ */
121
+ function buildIndexType(ou, model, type) {
122
+ return `${ou}${SEPARATOR}${model}${SEPARATOR}${type}`;
123
+ }
124
+ /**
125
+ * Build the indexXid key for external ID lookups
126
+ * @param ou - The organizational unit
127
+ * @param model - The entity model name
128
+ * @param xid - The external ID
129
+ * @returns Composite key: "{ou}#{model}#{xid}"
130
+ */
131
+ function buildIndexXid(ou, model, xid) {
132
+ return `${ou}${SEPARATOR}${model}${SEPARATOR}${xid}`;
133
+ }
134
+ /**
135
+ * Auto-populate GSI index keys on an entity
136
+ * - indexOu is always populated from ou + model
137
+ * - indexAlias is populated only when alias is present
138
+ * - indexClass is populated only when class is present
139
+ * - indexType is populated only when type is present
140
+ * - indexXid is populated only when xid is present
141
+ *
142
+ * @param entity - The entity to populate index keys for
143
+ * @param suffix - Optional suffix to append to all index keys (e.g., "#deleted", "#archived")
144
+ * @returns The entity with populated index keys
145
+ */
146
+ function indexEntity(entity, suffix = "") {
147
+ const result = { ...entity };
148
+ // indexOu is always set (from ou + model)
149
+ result.indexOu = buildIndexOu(entity.ou, entity.model) + suffix;
150
+ // Optional indexes - only set when the source field is present
151
+ if (entity.alias !== undefined) {
152
+ result.indexAlias =
153
+ buildIndexAlias(entity.ou, entity.model, entity.alias) + suffix;
154
+ }
155
+ if (entity.class !== undefined) {
156
+ result.indexClass =
157
+ buildIndexClass(entity.ou, entity.model, entity.class) + suffix;
158
+ }
159
+ if (entity.type !== undefined) {
160
+ result.indexType =
161
+ buildIndexType(entity.ou, entity.model, entity.type) + suffix;
162
+ }
163
+ if (entity.xid !== undefined) {
164
+ result.indexXid =
165
+ buildIndexXid(entity.ou, entity.model, entity.xid) + suffix;
166
+ }
167
+ return result;
168
+ }
169
+
170
+ /**
171
+ * Calculate suffix based on entity's archived/deleted state
172
+ */
173
+ function calculateEntitySuffix(entity) {
174
+ const hasArchived = Boolean(entity.archivedAt);
175
+ const hasDeleted = Boolean(entity.deletedAt);
176
+ if (hasArchived && hasDeleted) {
177
+ return ARCHIVED_SUFFIX + DELETED_SUFFIX;
178
+ }
179
+ if (hasArchived) {
180
+ return ARCHIVED_SUFFIX;
181
+ }
182
+ if (hasDeleted) {
183
+ return DELETED_SUFFIX;
184
+ }
185
+ return "";
186
+ }
187
+ /**
188
+ * Get a single entity by primary key
189
+ */
190
+ const getEntity = serviceHandler({
191
+ alias: "getEntity",
192
+ description: "Get a single entity by primary key",
193
+ input: {
194
+ id: { type: String, description: "Entity ID (sort key)" },
195
+ model: { type: String, description: "Entity model (partition key)" },
196
+ },
197
+ service: async ({ id, model }) => {
198
+ const docClient = getDocClient();
199
+ const tableName = getTableName();
200
+ const command = new GetCommand({
201
+ Key: { id, model },
202
+ TableName: tableName,
203
+ });
204
+ const response = await docClient.send(command);
205
+ return response.Item ?? null;
206
+ },
207
+ });
208
+ /**
209
+ * Put (create or replace) an entity
210
+ * Auto-populates GSI index keys via indexEntity
211
+ *
212
+ * Note: This is a regular async function (not serviceHandler) because it accepts
213
+ * complex FabricEntity objects that can't be coerced by vocabulary's type system.
214
+ */
215
+ async function putEntity({ entity, }) {
216
+ const docClient = getDocClient();
217
+ const tableName = getTableName();
218
+ // Auto-populate index keys
219
+ const indexedEntity = indexEntity(entity);
220
+ const command = new PutCommand({
221
+ Item: indexedEntity,
222
+ TableName: tableName,
223
+ });
224
+ await docClient.send(command);
225
+ return indexedEntity;
226
+ }
227
+ /**
228
+ * Update an existing entity
229
+ * Auto-populates GSI index keys and sets updatedAt
230
+ *
231
+ * Note: This is a regular async function (not serviceHandler) because it accepts
232
+ * complex FabricEntity objects that can't be coerced by vocabulary's type system.
233
+ */
234
+ async function updateEntity({ entity, }) {
235
+ const docClient = getDocClient();
236
+ const tableName = getTableName();
237
+ // Update timestamp and re-index
238
+ const updatedEntity = indexEntity({
239
+ ...entity,
240
+ updatedAt: new Date().toISOString(),
241
+ });
242
+ const command = new PutCommand({
243
+ Item: updatedEntity,
244
+ TableName: tableName,
245
+ });
246
+ await docClient.send(command);
247
+ return updatedEntity;
248
+ }
249
+ /**
250
+ * Soft delete an entity by setting deletedAt timestamp
251
+ * Re-indexes with appropriate suffix based on archived/deleted state
252
+ */
253
+ const deleteEntity = serviceHandler({
254
+ alias: "deleteEntity",
255
+ description: "Soft delete an entity (sets deletedAt timestamp)",
256
+ input: {
257
+ id: { type: String, description: "Entity ID (sort key)" },
258
+ model: { type: String, description: "Entity model (partition key)" },
259
+ },
260
+ service: async ({ id, model }) => {
261
+ const docClient = getDocClient();
262
+ const tableName = getTableName();
263
+ // Fetch the current entity
264
+ const existing = await getEntity({ id, model });
265
+ if (!existing) {
266
+ return false;
267
+ }
268
+ const now = new Date().toISOString();
269
+ // Build updated entity with deletedAt
270
+ const updatedEntity = {
271
+ ...existing,
272
+ deletedAt: now,
273
+ updatedAt: now,
274
+ };
275
+ // Calculate suffix based on combined state (may already be archived)
276
+ const suffix = calculateEntitySuffix(updatedEntity);
277
+ const deletedEntity = indexEntity(updatedEntity, suffix);
278
+ const command = new PutCommand({
279
+ Item: deletedEntity,
280
+ TableName: tableName,
281
+ });
282
+ await docClient.send(command);
283
+ return true;
284
+ },
285
+ });
286
+ /**
287
+ * Archive an entity by setting archivedAt timestamp
288
+ * Re-indexes with appropriate suffix based on archived/deleted state
289
+ */
290
+ const archiveEntity = serviceHandler({
291
+ alias: "archiveEntity",
292
+ description: "Archive an entity (sets archivedAt timestamp)",
293
+ input: {
294
+ id: { type: String, description: "Entity ID (sort key)" },
295
+ model: { type: String, description: "Entity model (partition key)" },
296
+ },
297
+ service: async ({ id, model }) => {
298
+ const docClient = getDocClient();
299
+ const tableName = getTableName();
300
+ // Fetch the current entity
301
+ const existing = await getEntity({ id, model });
302
+ if (!existing) {
303
+ return false;
304
+ }
305
+ const now = new Date().toISOString();
306
+ // Build updated entity with archivedAt
307
+ const updatedEntity = {
308
+ ...existing,
309
+ archivedAt: now,
310
+ updatedAt: now,
311
+ };
312
+ // Calculate suffix based on combined state (may already be deleted)
313
+ const suffix = calculateEntitySuffix(updatedEntity);
314
+ const archivedEntity = indexEntity(updatedEntity, suffix);
315
+ const command = new PutCommand({
316
+ Item: archivedEntity,
317
+ TableName: tableName,
318
+ });
319
+ await docClient.send(command);
320
+ return true;
321
+ },
322
+ });
323
+ /**
324
+ * Hard delete an entity (permanently removes from table)
325
+ * Use with caution - prefer deleteEntity for soft delete
326
+ */
327
+ const destroyEntity = serviceHandler({
328
+ alias: "destroyEntity",
329
+ description: "Hard delete an entity (permanently removes from table)",
330
+ input: {
331
+ id: { type: String, description: "Entity ID (sort key)" },
332
+ model: { type: String, description: "Entity model (partition key)" },
333
+ },
334
+ service: async ({ id, model }) => {
335
+ const docClient = getDocClient();
336
+ const tableName = getTableName();
337
+ const command = new DeleteCommand({
338
+ Key: { id, model },
339
+ TableName: tableName,
340
+ });
341
+ await docClient.send(command);
342
+ return true;
343
+ },
344
+ });
345
+
346
+ /**
347
+ * Calculate the suffix based on archived/deleted flags
348
+ * When both are true, returns combined suffix (archived first, alphabetically)
349
+ */
350
+ function calculateSuffix({ archived, deleted, }) {
351
+ if (archived && deleted) {
352
+ return ARCHIVED_SUFFIX + DELETED_SUFFIX;
353
+ }
354
+ if (archived) {
355
+ return ARCHIVED_SUFFIX;
356
+ }
357
+ if (deleted) {
358
+ return DELETED_SUFFIX;
359
+ }
360
+ return "";
361
+ }
362
+ /**
363
+ * Execute a GSI query with common options
364
+ */
365
+ async function executeQuery(indexName, keyValue, options = {}) {
366
+ const { ascending = false, limit, startKey } = options;
367
+ const docClient = getDocClient();
368
+ const tableName = getTableName();
369
+ const command = new QueryCommand({
370
+ ExclusiveStartKey: startKey,
371
+ ExpressionAttributeNames: {
372
+ "#pk": indexName,
373
+ },
374
+ ExpressionAttributeValues: {
375
+ ":pkValue": keyValue,
376
+ },
377
+ IndexName: indexName,
378
+ KeyConditionExpression: "#pk = :pkValue",
379
+ ...(limit && { Limit: limit }),
380
+ ScanIndexForward: ascending,
381
+ TableName: tableName,
382
+ });
383
+ const response = await docClient.send(command);
384
+ return {
385
+ items: (response.Items ?? []),
386
+ lastEvaluatedKey: response.LastEvaluatedKey,
387
+ };
388
+ }
389
+ /**
390
+ * Query entities by organizational unit (parent hierarchy)
391
+ * Uses indexOu GSI
392
+ *
393
+ * Note: This is a regular async function (not serviceHandler) because it accepts
394
+ * complex startKey objects that can't be coerced by vocabulary's type system.
395
+ */
396
+ async function queryByOu({ archived = false, ascending = false, deleted = false, limit, model, ou, startKey, }) {
397
+ const suffix = calculateSuffix({ archived, deleted });
398
+ const keyValue = buildIndexOu(ou, model) + suffix;
399
+ return executeQuery(INDEX_OU, keyValue, {
400
+ ascending,
401
+ limit,
402
+ startKey,
403
+ });
404
+ }
405
+ /**
406
+ * Query a single entity by human-friendly alias
407
+ * Uses indexAlias GSI
408
+ */
409
+ const queryByAlias = serviceHandler({
410
+ alias: "queryByAlias",
411
+ description: "Query a single entity by human-friendly alias",
412
+ input: {
413
+ alias: { type: String, description: "Human-friendly alias" },
414
+ archived: {
415
+ type: Boolean,
416
+ default: false,
417
+ required: false,
418
+ description: "Query archived entities instead of active ones",
419
+ },
420
+ deleted: {
421
+ type: Boolean,
422
+ default: false,
423
+ required: false,
424
+ description: "Query deleted entities instead of active ones",
425
+ },
426
+ model: { type: String, description: "Entity model name" },
427
+ ou: { type: String, description: "Organizational unit (@ for root)" },
428
+ },
429
+ service: async ({ alias, archived, deleted, model, ou, }) => {
430
+ const aliasStr = alias;
431
+ const archivedBool = archived;
432
+ const deletedBool = deleted;
433
+ const modelStr = model;
434
+ const ouStr = ou;
435
+ const suffix = calculateSuffix({ archived: archivedBool, deleted: deletedBool });
436
+ const keyValue = buildIndexAlias(ouStr, modelStr, aliasStr) + suffix;
437
+ const result = await executeQuery(INDEX_ALIAS, keyValue, {
438
+ limit: 1,
439
+ });
440
+ return result.items[0] ?? null;
441
+ },
442
+ });
443
+ /**
444
+ * Query entities by category classification
445
+ * Uses indexClass GSI
446
+ *
447
+ * Note: This is a regular async function (not serviceHandler) because it accepts
448
+ * complex startKey objects that can't be coerced by vocabulary's type system.
449
+ */
450
+ async function queryByClass({ archived = false, ascending = false, deleted = false, limit, model, ou, recordClass, startKey, }) {
451
+ const suffix = calculateSuffix({ archived, deleted });
452
+ const keyValue = buildIndexClass(ou, model, recordClass) + suffix;
453
+ return executeQuery(INDEX_CLASS, keyValue, {
454
+ ascending,
455
+ limit,
456
+ startKey,
457
+ });
458
+ }
459
+ /**
460
+ * Query entities by type classification
461
+ * Uses indexType GSI
462
+ *
463
+ * Note: This is a regular async function (not serviceHandler) because it accepts
464
+ * complex startKey objects that can't be coerced by vocabulary's type system.
465
+ */
466
+ async function queryByType({ archived = false, ascending = false, deleted = false, limit, model, ou, startKey, type, }) {
467
+ const suffix = calculateSuffix({ archived, deleted });
468
+ const keyValue = buildIndexType(ou, model, type) + suffix;
469
+ return executeQuery(INDEX_TYPE, keyValue, {
470
+ ascending,
471
+ limit,
472
+ startKey,
473
+ });
474
+ }
475
+ /**
476
+ * Query a single entity by external ID
477
+ * Uses indexXid GSI
478
+ */
479
+ const queryByXid = serviceHandler({
480
+ alias: "queryByXid",
481
+ description: "Query a single entity by external ID",
482
+ input: {
483
+ archived: {
484
+ type: Boolean,
485
+ default: false,
486
+ required: false,
487
+ description: "Query archived entities instead of active ones",
488
+ },
489
+ deleted: {
490
+ type: Boolean,
491
+ default: false,
492
+ required: false,
493
+ description: "Query deleted entities instead of active ones",
494
+ },
495
+ model: { type: String, description: "Entity model name" },
496
+ ou: { type: String, description: "Organizational unit (@ for root)" },
497
+ xid: { type: String, description: "External ID" },
498
+ },
499
+ service: async ({ archived, deleted, model, ou, xid, }) => {
500
+ const archivedBool = archived;
501
+ const deletedBool = deleted;
502
+ const modelStr = model;
503
+ const ouStr = ou;
504
+ const xidStr = xid;
505
+ const suffix = calculateSuffix({ archived: archivedBool, deleted: deletedBool });
506
+ const keyValue = buildIndexXid(ouStr, modelStr, xidStr) + suffix;
507
+ const result = await executeQuery(INDEX_XID, keyValue, {
508
+ limit: 1,
509
+ });
510
+ return result.items[0] ?? null;
511
+ },
512
+ });
513
+
514
+ const DEFAULT_ENDPOINT = "http://127.0.0.1:8000";
515
+ const DEFAULT_REGION$1 = "us-east-1";
516
+ const DEFAULT_TABLE_NAME$1 = "jaypie-local";
517
+ /**
518
+ * DynamoDB table schema with Jaypie GSI pattern
519
+ */
520
+ function createTableParams(tableName, billingMode) {
521
+ const gsiProjection = { ProjectionType: "ALL" };
522
+ return {
523
+ AttributeDefinitions: [
524
+ { AttributeName: "id", AttributeType: "S" },
525
+ { AttributeName: "indexAlias", AttributeType: "S" },
526
+ { AttributeName: "indexClass", AttributeType: "S" },
527
+ { AttributeName: "indexOu", AttributeType: "S" },
528
+ { AttributeName: "indexType", AttributeType: "S" },
529
+ { AttributeName: "indexXid", AttributeType: "S" },
530
+ { AttributeName: "model", AttributeType: "S" },
531
+ { AttributeName: "sequence", AttributeType: "N" },
532
+ ],
533
+ BillingMode: billingMode,
534
+ GlobalSecondaryIndexes: [
535
+ {
536
+ IndexName: "indexOu",
537
+ KeySchema: [
538
+ { AttributeName: "indexOu", KeyType: "HASH" },
539
+ { AttributeName: "sequence", KeyType: "RANGE" },
540
+ ],
541
+ Projection: gsiProjection,
542
+ },
543
+ {
544
+ IndexName: "indexAlias",
545
+ KeySchema: [
546
+ { AttributeName: "indexAlias", KeyType: "HASH" },
547
+ { AttributeName: "sequence", KeyType: "RANGE" },
548
+ ],
549
+ Projection: gsiProjection,
550
+ },
551
+ {
552
+ IndexName: "indexClass",
553
+ KeySchema: [
554
+ { AttributeName: "indexClass", KeyType: "HASH" },
555
+ { AttributeName: "sequence", KeyType: "RANGE" },
556
+ ],
557
+ Projection: gsiProjection,
558
+ },
559
+ {
560
+ IndexName: "indexType",
561
+ KeySchema: [
562
+ { AttributeName: "indexType", KeyType: "HASH" },
563
+ { AttributeName: "sequence", KeyType: "RANGE" },
564
+ ],
565
+ Projection: gsiProjection,
566
+ },
567
+ {
568
+ IndexName: "indexXid",
569
+ KeySchema: [
570
+ { AttributeName: "indexXid", KeyType: "HASH" },
571
+ { AttributeName: "sequence", KeyType: "RANGE" },
572
+ ],
573
+ Projection: gsiProjection,
574
+ },
575
+ ],
576
+ KeySchema: [
577
+ { AttributeName: "model", KeyType: "HASH" },
578
+ { AttributeName: "id", KeyType: "RANGE" },
579
+ ],
580
+ TableName: tableName,
581
+ };
582
+ }
583
+ /**
584
+ * Create DynamoDB table with Jaypie GSI schema
585
+ */
586
+ const createTableHandler = serviceHandler({
587
+ alias: "dynamodb_create_table",
588
+ description: "Create DynamoDB table with Jaypie GSI schema",
589
+ input: {
590
+ billingMode: {
591
+ type: ["PAY_PER_REQUEST", "PROVISIONED"],
592
+ default: "PAY_PER_REQUEST",
593
+ description: "DynamoDB billing mode",
594
+ },
595
+ endpoint: {
596
+ type: String,
597
+ default: DEFAULT_ENDPOINT,
598
+ description: "DynamoDB endpoint URL",
599
+ },
600
+ tableName: {
601
+ type: String,
602
+ default: DEFAULT_TABLE_NAME$1,
603
+ description: "Table name to create",
604
+ },
605
+ },
606
+ service: async ({ billingMode, endpoint, tableName }) => {
607
+ const endpointStr = endpoint;
608
+ const tableNameStr = tableName;
609
+ const billingModeStr = billingMode;
610
+ const client = new DynamoDBClient({
611
+ credentials: {
612
+ accessKeyId: "local",
613
+ secretAccessKey: "local",
614
+ },
615
+ endpoint: endpointStr,
616
+ region: DEFAULT_REGION$1,
617
+ });
618
+ try {
619
+ // Check if table already exists
620
+ await client.send(new DescribeTableCommand({ TableName: tableNameStr }));
621
+ return {
622
+ message: `Table "${tableNameStr}" already exists`,
623
+ success: false,
624
+ tableName: tableNameStr,
625
+ };
626
+ }
627
+ catch (error) {
628
+ if (error.name !== "ResourceNotFoundException") {
629
+ throw error;
630
+ }
631
+ }
632
+ // Create the table
633
+ const tableParams = createTableParams(tableNameStr, billingModeStr);
634
+ await client.send(new CreateTableCommand(tableParams));
635
+ return {
636
+ message: "Table created successfully",
637
+ success: true,
638
+ tableName: tableNameStr,
639
+ };
640
+ },
641
+ });
642
+
643
+ const DEFAULT_ADMIN_PORT = 8001;
644
+ const DEFAULT_DYNAMODB_PORT = 8000;
645
+ const DEFAULT_PROJECT_NAME = "jaypie";
646
+ const DEFAULT_TABLE_NAME = "jaypie-local";
647
+ /**
648
+ * Generate docker-compose.yml for local DynamoDB development
649
+ */
650
+ const dockerComposeHandler = serviceHandler({
651
+ alias: "dynamodb_generate_docker_compose",
652
+ description: "Generate docker-compose.yml for local DynamoDB development",
653
+ input: {
654
+ adminPort: {
655
+ type: Number,
656
+ default: DEFAULT_ADMIN_PORT,
657
+ description: "Port for DynamoDB Admin UI",
658
+ },
659
+ dynamodbPort: {
660
+ type: Number,
661
+ default: DEFAULT_DYNAMODB_PORT,
662
+ description: "Port for DynamoDB Local",
663
+ },
664
+ projectName: {
665
+ type: String,
666
+ default: DEFAULT_PROJECT_NAME,
667
+ description: "Project name for container naming",
668
+ },
669
+ tableName: {
670
+ type: String,
671
+ default: DEFAULT_TABLE_NAME,
672
+ description: "Default table name",
673
+ },
674
+ },
675
+ service: async ({ adminPort, dynamodbPort, projectName, tableName }) => {
676
+ const dockerCompose = `name: ${projectName}-dynamodb-stack
677
+
678
+ services:
679
+ dynamodb:
680
+ image: amazon/dynamodb-local:latest
681
+ container_name: ${projectName}-dynamodb
682
+ command: "-jar DynamoDBLocal.jar -sharedDb -dbPath /data"
683
+ ports:
684
+ - "${dynamodbPort}:8000"
685
+ working_dir: /home/dynamodblocal
686
+ volumes:
687
+ - dynamodb_data:/data
688
+ user: "0:0"
689
+
690
+ dynamodb-admin:
691
+ image: aaronshaf/dynamodb-admin:latest
692
+ container_name: ${projectName}-dynamodb-admin
693
+ ports:
694
+ - "${adminPort}:8001"
695
+ environment:
696
+ - DYNAMO_ENDPOINT=http://dynamodb:8000
697
+ - AWS_REGION=us-east-1
698
+ - AWS_ACCESS_KEY_ID=local
699
+ - AWS_SECRET_ACCESS_KEY=local
700
+ depends_on:
701
+ - dynamodb
702
+
703
+ volumes:
704
+ dynamodb_data:
705
+ driver: local
706
+ `;
707
+ const envVars = {
708
+ AWS_REGION: "us-east-1",
709
+ DYNAMODB_ENDPOINT: `http://127.0.0.1:${dynamodbPort}`,
710
+ DYNAMODB_TABLE_NAME: tableName,
711
+ };
712
+ const envFile = `# DynamoDB Local Configuration
713
+ DYNAMODB_TABLE_NAME=${tableName}
714
+ DYNAMODB_ENDPOINT=http://127.0.0.1:${dynamodbPort}
715
+ AWS_REGION=us-east-1
716
+ `;
717
+ return {
718
+ dockerCompose,
719
+ envFile,
720
+ envVars,
721
+ };
722
+ },
723
+ });
724
+
725
+ const DEFAULT_REGION = "us-east-1";
726
+ /**
727
+ * Ensure DynamoDB client is initialized from environment variables
728
+ * Called automatically before each MCP tool execution
729
+ */
730
+ function ensureInitialized() {
731
+ if (isInitialized()) {
732
+ return;
733
+ }
734
+ const tableName = process.env.DYNAMODB_TABLE_NAME;
735
+ if (!tableName) {
736
+ throw new ConfigurationError("DYNAMODB_TABLE_NAME environment variable is required");
737
+ }
738
+ initClient({
739
+ endpoint: process.env.DYNAMODB_ENDPOINT,
740
+ region: process.env.AWS_REGION || DEFAULT_REGION,
741
+ tableName,
742
+ });
743
+ }
744
+
745
+ /**
746
+ * Check DynamoDB connection status and configuration
747
+ */
748
+ const statusHandler = serviceHandler({
749
+ alias: "dynamodb_status",
750
+ description: "Check DynamoDB connection status and configuration",
751
+ service: async () => {
752
+ ensureInitialized();
753
+ return {
754
+ endpoint: process.env.DYNAMODB_ENDPOINT || "AWS Default",
755
+ initialized: isInitialized(),
756
+ region: process.env.AWS_REGION || "us-east-1",
757
+ tableName: getTableName(),
758
+ };
759
+ },
760
+ });
761
+
762
+ /**
763
+ * Wrap a handler to auto-initialize before execution
764
+ */
765
+ function wrapWithInit(handler) {
766
+ const wrapped = async (input) => {
767
+ ensureInitialized();
768
+ return handler(input);
769
+ };
770
+ // Preserve handler properties for MCP registration
771
+ Object.assign(wrapped, {
772
+ alias: handler.alias,
773
+ description: handler.description,
774
+ input: handler.input,
775
+ });
776
+ return wrapped;
777
+ }
778
+ // MCP-specific serviceHandler wrappers for functions with complex inputs
779
+ // Note: These wrap the regular async functions to make them work with registerMcpTool
780
+ /**
781
+ * MCP wrapper for putEntity
782
+ * Accepts entity JSON directly from LLM
783
+ */
784
+ const mcpPutEntity = serviceHandler({
785
+ alias: "dynamodb_put",
786
+ description: "Create or replace an entity in DynamoDB (auto-indexes GSI keys)",
787
+ input: {
788
+ // Required entity fields
789
+ id: { type: String, description: "Entity ID (sort key)" },
790
+ model: { type: String, description: "Entity model name (partition key)" },
791
+ name: { type: String, description: "Entity name" },
792
+ ou: { type: String, description: "Organizational unit (@ for root)" },
793
+ // Optional fields
794
+ alias: { type: String, required: false, description: "Human-friendly alias" },
795
+ class: { type: String, required: false, description: "Category classification" },
796
+ type: { type: String, required: false, description: "Type classification" },
797
+ xid: { type: String, required: false, description: "External ID" },
798
+ },
799
+ service: async (input) => {
800
+ const now = new Date().toISOString();
801
+ const entity = {
802
+ alias: input.alias,
803
+ class: input.class,
804
+ createdAt: now,
805
+ id: input.id,
806
+ model: input.model,
807
+ name: input.name,
808
+ ou: input.ou,
809
+ sequence: Date.now(),
810
+ type: input.type,
811
+ updatedAt: now,
812
+ xid: input.xid,
813
+ };
814
+ return putEntity({ entity });
815
+ },
816
+ });
817
+ /**
818
+ * MCP wrapper for updateEntity
819
+ * Accepts entity JSON directly from LLM
820
+ */
821
+ const mcpUpdateEntity = serviceHandler({
822
+ alias: "dynamodb_update",
823
+ description: "Update an entity in DynamoDB (sets updatedAt, re-indexes GSI keys)",
824
+ input: {
825
+ // Required fields to identify the entity
826
+ id: { type: String, description: "Entity ID (sort key)" },
827
+ model: { type: String, description: "Entity model name (partition key)" },
828
+ // Fields that can be updated
829
+ name: { type: String, required: false, description: "Entity name" },
830
+ ou: { type: String, required: false, description: "Organizational unit" },
831
+ alias: { type: String, required: false, description: "Human-friendly alias" },
832
+ class: { type: String, required: false, description: "Category classification" },
833
+ type: { type: String, required: false, description: "Type classification" },
834
+ xid: { type: String, required: false, description: "External ID" },
835
+ },
836
+ service: async (input) => {
837
+ // First get the existing entity
838
+ const existing = await getEntity({
839
+ id: input.id,
840
+ model: input.model,
841
+ });
842
+ if (!existing) {
843
+ return { error: "Entity not found", id: input.id, model: input.model };
844
+ }
845
+ // Merge updates
846
+ const entity = {
847
+ ...existing,
848
+ ...(input.alias !== undefined && { alias: input.alias }),
849
+ ...(input.class !== undefined && { class: input.class }),
850
+ ...(input.name !== undefined && { name: input.name }),
851
+ ...(input.ou !== undefined && { ou: input.ou }),
852
+ ...(input.type !== undefined && { type: input.type }),
853
+ ...(input.xid !== undefined && { xid: input.xid }),
854
+ };
855
+ return updateEntity({ entity });
856
+ },
857
+ });
858
+ /**
859
+ * MCP wrapper for queryByOu
860
+ * Note: Pagination via startKey is not exposed to MCP; use limit instead
861
+ */
862
+ const mcpQueryByOu = serviceHandler({
863
+ alias: "dynamodb_query_ou",
864
+ description: "Query entities by organizational unit (parent hierarchy)",
865
+ input: {
866
+ model: { type: String, description: "Entity model name" },
867
+ ou: { type: String, description: "Organizational unit (@ for root)" },
868
+ archived: {
869
+ type: Boolean,
870
+ default: false,
871
+ required: false,
872
+ description: "Query archived entities instead of active ones",
873
+ },
874
+ ascending: {
875
+ type: Boolean,
876
+ default: false,
877
+ required: false,
878
+ description: "Sort ascending (oldest first)",
879
+ },
880
+ deleted: {
881
+ type: Boolean,
882
+ default: false,
883
+ required: false,
884
+ description: "Query deleted entities instead of active ones",
885
+ },
886
+ limit: {
887
+ type: Number,
888
+ required: false,
889
+ description: "Maximum number of items to return",
890
+ },
891
+ },
892
+ service: async (input) => {
893
+ return queryByOu({
894
+ archived: input.archived,
895
+ ascending: input.ascending,
896
+ deleted: input.deleted,
897
+ limit: input.limit,
898
+ model: input.model,
899
+ ou: input.ou,
900
+ });
901
+ },
902
+ });
903
+ /**
904
+ * MCP wrapper for queryByClass
905
+ * Note: Pagination via startKey is not exposed to MCP; use limit instead
906
+ */
907
+ const mcpQueryByClass = serviceHandler({
908
+ alias: "dynamodb_query_class",
909
+ description: "Query entities by category classification",
910
+ input: {
911
+ model: { type: String, description: "Entity model name" },
912
+ ou: { type: String, description: "Organizational unit (@ for root)" },
913
+ recordClass: { type: String, description: "Category classification" },
914
+ archived: {
915
+ type: Boolean,
916
+ default: false,
917
+ required: false,
918
+ description: "Query archived entities instead of active ones",
919
+ },
920
+ ascending: {
921
+ type: Boolean,
922
+ default: false,
923
+ required: false,
924
+ description: "Sort ascending (oldest first)",
925
+ },
926
+ deleted: {
927
+ type: Boolean,
928
+ default: false,
929
+ required: false,
930
+ description: "Query deleted entities instead of active ones",
931
+ },
932
+ limit: {
933
+ type: Number,
934
+ required: false,
935
+ description: "Maximum number of items to return",
936
+ },
937
+ },
938
+ service: async (input) => {
939
+ return queryByClass({
940
+ archived: input.archived,
941
+ ascending: input.ascending,
942
+ deleted: input.deleted,
943
+ limit: input.limit,
944
+ model: input.model,
945
+ ou: input.ou,
946
+ recordClass: input.recordClass,
947
+ });
948
+ },
949
+ });
950
+ /**
951
+ * MCP wrapper for queryByType
952
+ * Note: Pagination via startKey is not exposed to MCP; use limit instead
953
+ */
954
+ const mcpQueryByType = serviceHandler({
955
+ alias: "dynamodb_query_type",
956
+ description: "Query entities by type classification",
957
+ input: {
958
+ model: { type: String, description: "Entity model name" },
959
+ ou: { type: String, description: "Organizational unit (@ for root)" },
960
+ type: { type: String, description: "Type classification" },
961
+ archived: {
962
+ type: Boolean,
963
+ default: false,
964
+ required: false,
965
+ description: "Query archived entities instead of active ones",
966
+ },
967
+ ascending: {
968
+ type: Boolean,
969
+ default: false,
970
+ required: false,
971
+ description: "Sort ascending (oldest first)",
972
+ },
973
+ deleted: {
974
+ type: Boolean,
975
+ default: false,
976
+ required: false,
977
+ description: "Query deleted entities instead of active ones",
978
+ },
979
+ limit: {
980
+ type: Number,
981
+ required: false,
982
+ description: "Maximum number of items to return",
983
+ },
984
+ },
985
+ service: async (input) => {
986
+ return queryByType({
987
+ archived: input.archived,
988
+ ascending: input.ascending,
989
+ deleted: input.deleted,
990
+ limit: input.limit,
991
+ model: input.model,
992
+ ou: input.ou,
993
+ type: input.type,
994
+ });
995
+ },
996
+ });
997
+ /**
998
+ * Register all DynamoDB MCP tools with a server
999
+ */
1000
+ function registerDynamoDbTools(config) {
1001
+ const { includeAdmin = true, server } = config;
1002
+ const tools = [];
1003
+ // Entity operations
1004
+ registerMcpTool({
1005
+ handler: wrapWithInit(getEntity),
1006
+ name: "dynamodb_get",
1007
+ server,
1008
+ });
1009
+ tools.push("dynamodb_get");
1010
+ registerMcpTool({
1011
+ handler: wrapWithInit(mcpPutEntity),
1012
+ name: "dynamodb_put",
1013
+ server,
1014
+ });
1015
+ tools.push("dynamodb_put");
1016
+ registerMcpTool({
1017
+ handler: wrapWithInit(mcpUpdateEntity),
1018
+ name: "dynamodb_update",
1019
+ server,
1020
+ });
1021
+ tools.push("dynamodb_update");
1022
+ registerMcpTool({
1023
+ handler: wrapWithInit(deleteEntity),
1024
+ name: "dynamodb_delete",
1025
+ server,
1026
+ });
1027
+ tools.push("dynamodb_delete");
1028
+ registerMcpTool({
1029
+ handler: wrapWithInit(archiveEntity),
1030
+ name: "dynamodb_archive",
1031
+ server,
1032
+ });
1033
+ tools.push("dynamodb_archive");
1034
+ registerMcpTool({
1035
+ handler: wrapWithInit(destroyEntity),
1036
+ name: "dynamodb_destroy",
1037
+ server,
1038
+ });
1039
+ tools.push("dynamodb_destroy");
1040
+ // Query operations
1041
+ registerMcpTool({
1042
+ handler: wrapWithInit(mcpQueryByOu),
1043
+ name: "dynamodb_query_ou",
1044
+ server,
1045
+ });
1046
+ tools.push("dynamodb_query_ou");
1047
+ registerMcpTool({
1048
+ handler: wrapWithInit(queryByAlias),
1049
+ name: "dynamodb_query_alias",
1050
+ server,
1051
+ });
1052
+ tools.push("dynamodb_query_alias");
1053
+ registerMcpTool({
1054
+ handler: wrapWithInit(mcpQueryByClass),
1055
+ name: "dynamodb_query_class",
1056
+ server,
1057
+ });
1058
+ tools.push("dynamodb_query_class");
1059
+ registerMcpTool({
1060
+ handler: wrapWithInit(mcpQueryByType),
1061
+ name: "dynamodb_query_type",
1062
+ server,
1063
+ });
1064
+ tools.push("dynamodb_query_type");
1065
+ registerMcpTool({
1066
+ handler: wrapWithInit(queryByXid),
1067
+ name: "dynamodb_query_xid",
1068
+ server,
1069
+ });
1070
+ tools.push("dynamodb_query_xid");
1071
+ // Admin tools (MCP-only)
1072
+ if (includeAdmin) {
1073
+ registerMcpTool({ handler: statusHandler, server });
1074
+ tools.push("dynamodb_status");
1075
+ registerMcpTool({ handler: createTableHandler, server });
1076
+ tools.push("dynamodb_create_table");
1077
+ registerMcpTool({ handler: dockerComposeHandler, server });
1078
+ tools.push("dynamodb_generate_docker_compose");
1079
+ }
1080
+ return { tools };
1081
+ }
1082
+
1083
+ export { createTableHandler, dockerComposeHandler, ensureInitialized, registerDynamoDbTools, statusHandler };
1084
+ //# sourceMappingURL=index.js.map