@naanlang/naan 1.0.9 → 1.0.11

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.
@@ -137,10 +137,15 @@ exports.NaanWorkerActivate = function NaanWorkerActivate(cbReady) {
137
137
  naanlib.js.h = naanlib.js.d; // set host directory path
138
138
  if (msg.dirpath)
139
139
  naanlib.js.d = msg.dirpath; // set owner directory path
140
+ if (msg.altcmd)
141
+ naanlib.textLine(msg.altcmd);
142
+ /*
143
+ ### already started ###
140
144
  naanlib.start({
141
145
  state: msg.state,
142
146
  cmd: msg.altcmd
143
147
  });
148
+ */
144
149
  }
145
150
  else if (msg.id == "import")
146
151
  importScripts(msg.script); // load a JavaScript
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@naanlang/naan",
3
- "version": "1.0.9",
3
+ "version": "1.0.11",
4
4
  "author": "Richard C. Zulch",
5
5
  "description": "Naan™ software platform",
6
6
  "main": "./lib/core/naanlib.js",
@@ -0,0 +1,490 @@
1
+ /*
2
+ * openSearch.nlg
3
+ * openSearch
4
+ *
5
+ * OpenSearch client.
6
+ *
7
+ * column positioning: // // !
8
+ *
9
+ * Copyright (c) 2023 by Richard C. Zulch
10
+ *
11
+ */
12
+
13
+ /*
14
+ * openSearchIndex
15
+ *
16
+ * Access object for API functions on a single index.
17
+ *
18
+ * This client provides a custom datatype facility as a layer on top of OpenSearch. A schema defines
19
+ * the fields in an index, which are compiled into a mapping dictionary. The extra layer allows for
20
+ * adding additional configuration to the field.
21
+ *
22
+ * The inbuilt schema types are:
23
+ * `literal -- keyword
24
+ * `title -- text for a title
25
+ * `hashtags -- set of hashtags
26
+ * `date -- date
27
+ * `boolean -- boolean
28
+ * `currency -- currency with type
29
+ * `text -- block of text
30
+ * `name -- proper name, first-last-middle-etc.
31
+ * `integer -- integer
32
+ * `url -- URL
33
+ *
34
+ * Current options are:
35
+ * {
36
+ * languages: [
37
+ * <language>: {
38
+ * keywords: [<strings>...] -- lowercase keywords not to stem
39
+ * }
40
+ * ...
41
+ * ]
42
+ * }
43
+ *
44
+ */
45
+
46
+ closure openSearchIndex(prosc, indexName, ioptions, local osin) {
47
+ global()
48
+ osin = new(object, this)
49
+ if symbol(indexName)
50
+ indexName = indexName.tostring()
51
+ osin.analyzers = { // map names, language to analyzer
52
+ english: "english"
53
+ }
54
+
55
+ // langKeywords
56
+ //
57
+ // Customize the specified language with an array of keywords that will not be stemmed. This is
58
+ // useful for proper names like Дарья and Таня which otherwise become three-letter words that
59
+ // match too many things. Note that the keywords must be lowercase.
60
+ //
61
+ function langKeywords(language, keywords,
62
+ local keywordFilter, stopFilter, stemFilter, params) {
63
+ keywordFilter = "naan_${language}_keywords"
64
+ stopFilter = "naan_${language}_stop"
65
+ stemFilter = "naan_${language}_stemmer"
66
+ filters = [ "lowercase", keywordFilter, stopFilter, stemFilter ]
67
+ params = {
68
+ settings: {
69
+ analysis: {
70
+ filter: { }
71
+ analyzer: { }
72
+ }
73
+ }
74
+ }
75
+ osin.analyzers[language] = "naan_${language}"
76
+ params.settings.analysis.analyzer[osin.analyzers[language]] = {
77
+ tokenizer: "standard"
78
+ filter: filters
79
+ }
80
+ params.settings.analysis.filter[keywordFilter] = {
81
+ type: "keyword_marker"
82
+ keywords: keywords
83
+ }
84
+ params.settings.analysis.filter[stopFilter] = {
85
+ type: "stop"
86
+ stopwords: "_${language}_"
87
+ }
88
+ params.settings.analysis.filter[stemFilter] = {
89
+ type: "stemmer"
90
+ language: language
91
+ }
92
+ params
93
+ }
94
+
95
+ // textRawMF
96
+ //
97
+ // Return a text+raw multifield in the languages configured for our client.
98
+ //
99
+ function textRawMF(local fieldspec, language) {
100
+ fieldspec = {
101
+ type: "text"
102
+ fields: {
103
+ raw: {
104
+ "type": "keyword"
105
+ }
106
+ }
107
+ }
108
+ for language in osin.analyzers
109
+ fieldspec.fields[language] = { // add languages
110
+ type: "text"
111
+ analyzer: osin.analyzers[language]
112
+ }
113
+ fieldspec
114
+ }
115
+
116
+ // makeMapping
117
+ //
118
+ // Make an OpenSearch mapping dictionary for the specified dictionary of field names/types.
119
+ //
120
+ function makeMapping(fields, local mapping, fname, ftype, osfield) {
121
+ osin.fields = fields
122
+ osin.fieldNames = []
123
+ mapping = { }
124
+ for `(fname, ftype) in fields {
125
+ osin.fieldNames.push(fname)
126
+ osfield = eval({
127
+ //
128
+ // literal
129
+ //
130
+ literal: quote({
131
+ type: "keyword"
132
+ })
133
+ //
134
+ // title
135
+ //
136
+ title: quote(
137
+ textRawMF()
138
+ )
139
+ //
140
+ // hashtags
141
+ //
142
+ hashtags: quote(
143
+ textRawMF()
144
+ )
145
+ //
146
+ // date
147
+ //
148
+ date: quote(
149
+ textRawMF()
150
+ )
151
+ //
152
+ // boolean
153
+ //
154
+ boolean: quote(
155
+ textRawMF()
156
+ )
157
+ //
158
+ // currency
159
+ //
160
+ currency: quote(
161
+ textRawMF()
162
+ )
163
+ //
164
+ // text
165
+ //
166
+ text: quote(
167
+ textRawMF()
168
+ )
169
+ //
170
+ // name
171
+ //
172
+ name: quote(
173
+ textRawMF()
174
+ )
175
+ //
176
+ // integer
177
+ //
178
+ integer: quote(
179
+ textRawMF()
180
+ )
181
+ //
182
+ // url
183
+ //
184
+ url: quote(
185
+ textRawMF()
186
+ )
187
+ }[ftype])
188
+ if !osfield
189
+ debuglog("OpenSearchClient.makeMapping: unknown field type ${ftype}")
190
+ else {
191
+ osfield.meta = {
192
+ type: ftype
193
+ }
194
+ mapping[fname] = osfield
195
+ }
196
+ }
197
+ mapping
198
+ }
199
+
200
+ // bulkDocs
201
+ //
202
+ // Create a bulk docs request body with the specified operation. Returns a standard
203
+ // `(error, string) result tuple.
204
+ //
205
+ function bulkDocs(docs, action, local items, doc, index, operation, error, data) {
206
+ items = []
207
+ for `(doc, index) in docs {
208
+ operation = { }
209
+ operation[action] = { _index: indexName }
210
+ `(error, data) = JsonStringify(operation)
211
+ if !error
212
+ `(error, doc) = JsonStringify(doc)
213
+ if error {
214
+ error = Error("openSearchIndex.write: doc[${index}] unwritable:", error)
215
+ ErrorDebuglog(error)
216
+ return (list(error))
217
+ }
218
+ items.push(data)
219
+ items.push(doc)
220
+ }
221
+ list(false, items.join("\n").concat("\n"))
222
+ }
223
+
224
+ // schema
225
+ //
226
+ // Get the schema for the named index.
227
+ //
228
+ osin.schema = closure schema(local error, data, fields, key, osfield) {
229
+ `(error, data) = prosc.request(indexName.concat("/_mapping"), {
230
+ method: "GET"
231
+ })
232
+ if error
233
+ return (list(error))
234
+ osin.fieldNames = []
235
+ fields = {}
236
+ for `(key, osfield) in data[indexName].mappings.properties {
237
+ osin.fieldNames.push(key)
238
+ fields[key] = osfield.meta.type
239
+ }
240
+ list(false, fields)
241
+ }
242
+
243
+ // createIndex
244
+ //
245
+ // Create the index with the specified field types.
246
+ //
247
+ osin.createIndex = closure createIndex(fields, local params, language, opts) {
248
+ params = { }
249
+ if ioptions.languages {
250
+ for `(language, opts) in ioptions.languages
251
+ if opts.keywords
252
+ params = merge(params, langKeywords(language, opts.keywords))
253
+ else
254
+ osin.analyzers[language] = language
255
+ }
256
+ params = merge(params, {
257
+ mappings: {
258
+ properties: makeMapping(fields)
259
+ }
260
+ })
261
+ // printline("OpenSearchClient.create:\n${Dialect.print(params)}")
262
+ prosc.request(indexName, {
263
+ method: "PUT"
264
+ putdata: params
265
+ })
266
+ }
267
+
268
+ // deleteIndex
269
+ //
270
+ // Delete the index.
271
+ //
272
+ osin.deleteIndex = closure deleteIndex() {
273
+ prosc.request(indexName, {
274
+ method: "DELETE"
275
+ })
276
+ }
277
+
278
+ // write
279
+ //
280
+ // Write a document or an array of documents to our index. Returns a standard `(error, data)
281
+ // tuple where the data is the _id (or [_ids]) when there was no error.
282
+ //
283
+ osin.write = closure write(doc, local error, docs, data) {
284
+ if array(doc) {
285
+ `(error, docs) = bulkDocs(doc, "create")
286
+ if docs
287
+ `(error, data) = prosc.request(indexName.concat("/_bulk"), {
288
+ method: "POST"
289
+ putdata: docs
290
+ contentType: "application/json"
291
+ })
292
+ if data
293
+ data = data.items.map(function(item) {
294
+ item.create._id
295
+ })
296
+ }
297
+ else {
298
+ `(error, data) = write([doc])
299
+ if data
300
+ data = data.0
301
+ }
302
+ list(error, data)
303
+ }
304
+
305
+ // update
306
+ //
307
+ // Update a document in our index.
308
+ //
309
+ osin.update = closure update(doc, _id) {
310
+ prosc.request(indexName.concat("/_update/${_id}"), {
311
+ method: "POST"
312
+ putdata: {
313
+ doc: doc
314
+ }
315
+ })
316
+ }
317
+
318
+ // delete
319
+ //
320
+ // Remove a document from our index.
321
+ //
322
+ osin.delete = closure delete(_id) {
323
+ prosc.request(indexName.concat("/_doc/${_id}"), {
324
+ method: "DELETE"
325
+ })
326
+ }
327
+
328
+ // search
329
+ //
330
+ // Simple search on our index, default to all fields. Options are:
331
+ // {
332
+ // where: <array> -- fields to search, defaulting to * (all)
333
+ // qopts: <dictionary> -- OpenSearch query options
334
+ // }
335
+ //
336
+ osin.search = closure search(what, soptions, local where, params) {
337
+ where = soptions.where
338
+ if !array(where)
339
+ where = ["*"]
340
+ params = {
341
+ query: {
342
+ multi_match: {
343
+ query: what
344
+ fields: where
345
+ }
346
+ }
347
+ }
348
+ if dictionary(soptions.qopts)
349
+ params.query.multi_match = merge(params.query.multi_match, soptions.qopts)
350
+ // printline("OpenSearchClient.search:\n${Dialect.print(params)}")
351
+ prosc.request(indexName.concat("/_search"), {
352
+ method: "POST"
353
+ putdata: params
354
+ })
355
+ }
356
+
357
+ // finis
358
+
359
+ osin
360
+ };
361
+
362
+
363
+ /*
364
+ * OpenSearchClient
365
+ *
366
+ * An OpenSearchClient client as a wrapper for the REST API. Options are:
367
+ * {
368
+ * url: <string> -- host URL with port
369
+ * cacert: <data> -- CA pem for host
370
+ * auth: <user>:<password> -- basic authentication
371
+ * debug: <boolean> -- debuglog additional error information
372
+ * languages: [
373
+ * <language>: {
374
+ * keywords: [<strings>...] -- lowercase keywords not to stem
375
+ * }
376
+ * ...
377
+ * ]
378
+ * }
379
+ *
380
+ */
381
+
382
+ closure OpenSearchClient(goptions, local oscl, prosc) {
383
+ global(JSpath)
384
+ oscl = new(object, this)
385
+ prosc = new(object, this)
386
+ prosc.oscl = oscl
387
+ goptions = new(goptions)
388
+ if goptions.url.slice(-1) != "/"
389
+ goptions.url = goptions.url.concat("/")
390
+
391
+ // request
392
+ //
393
+ // Perform a request on the host as specified.
394
+ //
395
+ prosc.request = closure request(path, roptions, local url, error, data) {
396
+ if roptions
397
+ roptions = new(roptions)
398
+ else
399
+ roptions = { }
400
+ roptions.debug = goptions.debug
401
+ roptions.auth = goptions.auth
402
+ roptions.cacert = goptions.cacert
403
+ roptions.allowSelfSigned = true
404
+ url = goptions.url.concat(path)
405
+ `(error, data) = HttpsApiRequest(url, roptions)
406
+ }
407
+
408
+ // ping
409
+ //
410
+ // Ping the cluster and get basic ID and version information.
411
+ //
412
+ oscl.ping = closure ping() {
413
+ request("")
414
+ }
415
+
416
+ // index
417
+ //
418
+ // Return an object for the named index, which may not exist yet.
419
+ //
420
+ oscl.index = closure index(indexName) {
421
+ openSearchIndex(prosc, indexName, goptions)
422
+ }
423
+
424
+ // search
425
+ //
426
+ // Simple search on all indexes and fields. Options are:
427
+ // {
428
+ // indexes: <array> -- indexes to search, default all
429
+ // size: <integer> -- results to return, default 10
430
+ // query: <dictionary> -- OpenSearch query, default multi_match
431
+ // qopts: <dictionary> -- OpenSearch query options for multi_match
432
+ // filter: <dictionary> - OpenSearch query filter
433
+ // }
434
+ //
435
+ oscl.search = closure search(what, soptions, local params, query, path) {
436
+ params = { }
437
+ query = {
438
+ multi_match: {
439
+ query: what
440
+ fields: "*"
441
+ fuzziness: "auto"
442
+ }
443
+ }
444
+ if soptions.size
445
+ params.size = soptions.size // results to return
446
+ if soptions.query
447
+ query = soptions.query
448
+ if dictionary(soptions.qopts)
449
+ query.multi_match = merge(query.multi_match, soptions.qopts)
450
+ if dictionary(soptions.filter) {
451
+ query = {
452
+ bool: {
453
+ must: query
454
+ filter: soptions.filter
455
+ }
456
+ }
457
+ }
458
+ params.query = query
459
+ path = "_search"
460
+ if soptions.indexes
461
+ path = soptions.indexes.join(",").concat("/", path)
462
+ prosc.request(path, {
463
+ method: "POST"
464
+ putdata: params
465
+ })
466
+ }
467
+
468
+ // finis
469
+
470
+ oscl
471
+ };
472
+
473
+
474
+ /*
475
+ * opseInit
476
+ *
477
+ * Initialize the module.
478
+ *
479
+ */
480
+
481
+ function opseInit(local manifest) {
482
+ manifest = `(openSearchIndex, OpenSearchClient, opseInit)
483
+
484
+ Naan.module.build(module.id, "openSearch", function(modobj, compobj) {
485
+ compobj.manifest = manifest
486
+ require("frameworks/common").LiveImport()
487
+ letimport(require("frameworks/node/https_request.nlg"))
488
+ modobj.exports.OpenSearchClient = OpenSearchClient
489
+ })
490
+ } ();
@@ -0,0 +1,195 @@
1
+ /*
2
+ * os_dynamo.nlg
3
+ * openSearch
4
+ *
5
+ * OpenSearch-DynamoDB integration.
6
+ *
7
+ * column positioning: // // !
8
+ *
9
+ * Copyright (c) 2023 by Richard C. Zulch
10
+ *
11
+ */
12
+
13
+ /*
14
+ * OSDynIndexer
15
+ *
16
+ * Index a dynamoDB database into an OpenSearch index. The index should be created before this is
17
+ * called, with the proper fields and types already configured. The database is the source of truth,
18
+ * so the index can be recreated from scratch if necessary. Source _ids are assigned by OpenSearch
19
+ * and therefore this creates all new records. It is not a sync tool.
20
+ * This operates by scanning the database for a page of records, then starting the indexing
21
+ * operation in the background, scanning another batch, and then waiting for the previous background
22
+ * index to complete. Each time it has both a set of database records and the ids from indexing, it
23
+ * can optionally update the database records with the newly-assigned ID. There is never more than
24
+ * one database or index operation proceeding at the same time, but they overlap.
25
+ *
26
+ * Options:
27
+ * {
28
+ * chunkSize: <integer> // size of each chunk of transfer
29
+ * dbFilter: <array-or-dict> // DynamoDB KeyExpression(s)
30
+ * converter: <procedure> // convert DB record to index info
31
+ * progress: <procedure> // called for each batch completed
32
+ * writeID: <string> // write OpenSearch _id to database with key writeID
33
+ * }
34
+ *
35
+ * The converter protocol is:
36
+ * converter(dbRecord) // return _source doc for database record
37
+ * The progress protocol is:
38
+ * progress(completed) // integer number of records completed
39
+ *
40
+ */
41
+
42
+ closure OSDynIndexer(index, table, options,
43
+ local dbOptions, scanPending, indexPending, updatePending, output, error, records, data, ids) {
44
+ if !options.converter
45
+ return (Error("OSDynIndexer: options.converter is required"))
46
+ dbOptions = {
47
+ filter: options.filter
48
+ limit: options.chunkSize || 100
49
+ }
50
+ //
51
+ // Scan records in the database, returning a nonce signaled with records array
52
+ //
53
+ closure scanRecords(local pending) {
54
+ if !output
55
+ output = [] // just starting to scan records
56
+ else if !dbOptions.paging
57
+ return (false) // scanned all records
58
+ pending = new(nonce)
59
+ future(function() {
60
+ pending.signal(table.scanRecords(dbOptions))
61
+ },0)
62
+ pending
63
+ }
64
+ //
65
+ // Update ids into the database, returning a nonce signaled with error array.
66
+ //
67
+ closure updateRecordIDs(docs, ids, writeID) {
68
+ asyncArray(docs, 10, function(entry, index, cancel, local error) {
69
+ ignore(error)
70
+ `(error) = table.updateRecord(
71
+ entry[table.hashKey],
72
+ entry[table.rangeKey], {
73
+ set: [list(writeID, ids[index])]
74
+ })
75
+ error
76
+ })
77
+ }
78
+ //
79
+ // Batch index documents into OpenSearch, returning a nonce signaled with ids array.
80
+ //
81
+ closure batchIndex(records, local pending, docs) {
82
+ pending = new(nonce)
83
+ docs = records.map(function(record) {
84
+ options.converter(record)
85
+ })
86
+ future(function(local error, ids) {
87
+ `(error, ids) = index.write(docs)
88
+ pending.signal(list(error, records, ids))
89
+ }, 0)
90
+ pending
91
+ }
92
+ //
93
+ // execute
94
+ //
95
+ // loop operation
96
+ // 1 scan(a)start
97
+ // 2 scan(a)end,index(a)start,scan(b)start,index(a)end,update(a)start
98
+ // 3 scan(b)end,index(b)start,scan(c)start,index(b)end,update(a)end,update(b)start
99
+ // 4 scan(c)end,index(c)start,scan(d)start,index(c)end,update(b)end,update(c)start
100
+ //
101
+ // |-----------|-----------| |-----------|-----------|
102
+ // | 1.scan(a) | 3.scan(b) | | 6.scan(c) | 9.scan(d) |
103
+ // |-----------|-----------| |-----------|-----------|
104
+ // |------------|------------| |-------------|
105
+ // | 2.index(a) | 5.index(b) | | 8.index(c) |
106
+ // |------------|------------| |-------------|
107
+ // |---------------|---------------| |----------------|
108
+ // | 4.update(a,a) | 7.update(b,b) | | 10.update(c,c) |
109
+ // |---------------|---------------| |----------------|
110
+ //
111
+ // As of 2023-04-12 with Yandex Cloud and a minimal OpenSearch cluster, The indexing operation is
112
+ // the long pole in the tent--taking the longest time to process. However the timing has a lot of
113
+ // variation, e.g. the first DynamoDB update being longer but then very fast after that.
114
+ //
115
+ loop {
116
+ //
117
+ // finish scanning
118
+ //
119
+ if scanPending {
120
+ `(error, records) = scanPending.wait()
121
+ scanPending = false
122
+ if error
123
+ break
124
+ }
125
+ //
126
+ // start indexing
127
+ //
128
+ if records.length > 0
129
+ indexPending = batchIndex(records)
130
+ records = false
131
+ //
132
+ // start scanning
133
+ //
134
+ if !output || output.length <= Number.MAX_SAFE_INTEGER // for future limit option
135
+ scanPending = scanRecords()
136
+ //
137
+ // finish indexing
138
+ //
139
+ if indexPending {
140
+ `(error, records, ids) = indexPending.wait()
141
+ indexPending = false
142
+ if error
143
+ break
144
+ output = output.concat(ids)
145
+ }
146
+ //
147
+ // finish updating
148
+ //
149
+ if updatePending {
150
+ `(data) = updatePending.wait()
151
+ updatePending = false
152
+ error = data.find(function(item) { item })
153
+ if error
154
+ break
155
+ options.progress(output.length) // progress with update
156
+ }
157
+ //
158
+ // start updating
159
+ //
160
+ if options.writeID {
161
+ if records && ids
162
+ updatePending = updateRecordIDs(records, ids, options.writeID)
163
+ }
164
+ else
165
+ options.progress(output.length || 0) // progress without update
166
+ records = ids = false
167
+ //
168
+ // check for termination
169
+ //
170
+ if !scanPending && !indexPending && !updatePending
171
+ break
172
+ }
173
+ if error
174
+ list(error)
175
+ else
176
+ list(false, output)
177
+ };
178
+
179
+
180
+ /*
181
+ * osdyInit
182
+ *
183
+ * Initialize the module.
184
+ *
185
+ */
186
+
187
+ function osdyInit(local manifest) {
188
+ manifest = `(OSDynIndexer, osdyInit)
189
+
190
+ Naan.module.build(module.id, "os_dynamo", function(modobj, compobj) {
191
+ compobj.manifest = manifest
192
+ require("./openSearch.nlg")
193
+ modobj.exports.OSDynIndexer = OSDynIndexer
194
+ })
195
+ } ();