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